Show identity token at purchase screen when not using play store builds

This commit is contained in:
Jonas Lochmann 2022-09-12 02:00:00 +02:00
parent 8dbc800b6c
commit 61253a422c
No known key found for this signature in database
GPG key ID: 8B8C9AEE10FA5B36
10 changed files with 312 additions and 75 deletions

View file

@ -1,5 +1,5 @@
/* /*
* TimeLimit Copyright <C> 2019 - 2021 Jonas Lochmann * TimeLimit Copyright <C> 2019 - 2022 Jonas Lochmann
* *
* This program is free software: you can redistribute it and/or modify * This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by * it under the terms of the GNU General Public License as published by
@ -55,6 +55,7 @@ interface ServerApi {
suspend fun reportDeviceRemoved(deviceAuthToken: String) suspend fun reportDeviceRemoved(deviceAuthToken: String)
suspend fun removeDevice(deviceAuthToken: String, parentUserId: String, parentPasswordSecondHash: String, deviceId: String) suspend fun removeDevice(deviceAuthToken: String, parentUserId: String, parentPasswordSecondHash: String, deviceId: String)
suspend fun isDeviceRemoved(deviceAuthToken: String): Boolean suspend fun isDeviceRemoved(deviceAuthToken: String): Boolean
suspend fun createIdentityToken(deviceAuthToken: String, parentUserId: String, parentPasswordSecondHash: String): String
} }
class MailServerBlacklistedException: RuntimeException() class MailServerBlacklistedException: RuntimeException()

View file

@ -1,5 +1,5 @@
/* /*
* TimeLimit Copyright <C> 2019 - 2021 Jonas Lochmann * TimeLimit Copyright <C> 2019 - 2022 Jonas Lochmann
* *
* This program is free software: you can redistribute it and/or modify * This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by * it under the terms of the GNU General Public License as published by
@ -98,4 +98,12 @@ class DummyServerApi: ServerApi {
override suspend fun isDeviceRemoved(deviceAuthToken: String): Boolean { override suspend fun isDeviceRemoved(deviceAuthToken: String): Boolean {
throw IOException() throw IOException()
} }
override suspend fun createIdentityToken(
deviceAuthToken: String,
parentUserId: String,
parentPasswordSecondHash: String
): String {
throw IOException()
}
} }

View file

@ -1,5 +1,5 @@
/* /*
* TimeLimit Copyright <C> 2019 - 2021 Jonas Lochmann * TimeLimit Copyright <C> 2019 - 2022 Jonas Lochmann
* *
* This program is free software: you can redistribute it and/or modify * This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by * it under the terms of the GNU General Public License as published by
@ -626,4 +626,43 @@ class HttpServerApi(private val endpointWithoutSlashAtEnd: String): ServerApi {
} }
} }
} }
override suspend fun createIdentityToken(
deviceAuthToken: String,
parentUserId: String,
parentPasswordSecondHash: String
): String {
httpClient.newCall(
Request.Builder()
.url("$endpointWithoutSlashAtEnd/parent/create-identity-token")
.post(createJsonRequestBody { writer ->
writer.beginObject()
writer.name(DEVICE_AUTH_TOKEN).value(deviceAuthToken)
writer.name(PARENT_USER_ID).value(parentUserId)
writer.name(PARENT_PASSWORD_SECOND_HASH).value(parentPasswordSecondHash)
writer.name("purpose").value("purchase")
writer.endObject()
})
.header("Content-Encoding", "gzip")
.build()
).waitForResponse().use { response ->
response.assertSuccess()
return Threads.network.executeAndWait {
val reader = JsonReader(response.body!!.charStream())
var token: String? = null
reader.beginObject()
while (reader.hasNext()) {
when (reader.nextName()) {
"token" -> token = reader.nextString()
else -> reader.skipValue()
}
}
reader.endObject()
token!!
}
}
}
} }

View file

@ -1,5 +1,5 @@
/* /*
* TimeLimit Copyright <C> 2019 Jonas Lochmann * TimeLimit Copyright <C> 2019 - 2022 Jonas Lochmann
* *
* This program is free software: you can redistribute it and/or modify * This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by * it under the terms of the GNU General Public License as published by
@ -16,16 +16,13 @@
package io.timelimit.android.ui.diagnose package io.timelimit.android.ui.diagnose
import android.app.Dialog import android.app.Dialog
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.os.Bundle import android.os.Bundle
import android.widget.Toast
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.DialogFragment import androidx.fragment.app.DialogFragment
import androidx.fragment.app.FragmentManager import androidx.fragment.app.FragmentManager
import io.timelimit.android.R import io.timelimit.android.R
import io.timelimit.android.extensions.showSafe import io.timelimit.android.extensions.showSafe
import io.timelimit.android.util.Clipboard
import java.io.PrintWriter import java.io.PrintWriter
import java.io.StringWriter import java.io.StringWriter
@ -51,16 +48,11 @@ class DiagnoseExceptionDialogFragment: DialogFragment() {
} }
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val message = getStackTraceString(arguments!!.getSerializable(EXCEPTION) as Exception) val message = getStackTraceString(requireArguments().getSerializable(EXCEPTION) as Exception)
val clipboard = context!!.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
return AlertDialog.Builder(context!!, theme) return AlertDialog.Builder(requireContext(), theme)
.setMessage(message) .setMessage(message)
.setNeutralButton(R.string.diagnose_sync_copy_to_clipboard) { _, _ -> .setNeutralButton(R.string.diagnose_sync_copy_to_clipboard) { _, _ -> Clipboard.setAndToast(requireContext(), message) }
clipboard.setPrimaryClip(ClipData.newPlainText("TimeLimit", message))
Toast.makeText(context, R.string.diagnose_sync_copied_to_clipboard, Toast.LENGTH_SHORT).show()
}
.setPositiveButton(R.string.generic_ok, null) .setPositiveButton(R.string.generic_ok, null)
.create() .create()
} }

View file

@ -28,7 +28,10 @@ import io.timelimit.android.livedata.liveDataFromNullableValue
import io.timelimit.android.livedata.mergeLiveData import io.timelimit.android.livedata.mergeLiveData
import io.timelimit.android.ui.MainActivity import io.timelimit.android.ui.MainActivity
import io.timelimit.android.ui.diagnose.DiagnoseExceptionDialogFragment import io.timelimit.android.ui.diagnose.DiagnoseExceptionDialogFragment
import io.timelimit.android.ui.main.ActivityViewModelHolder
import io.timelimit.android.ui.main.FragmentWithCustomTitle import io.timelimit.android.ui.main.FragmentWithCustomTitle
import io.timelimit.android.ui.main.getActivityViewModel
import io.timelimit.android.util.Clipboard
import java.lang.RuntimeException import java.lang.RuntimeException
class PurchaseFragment : Fragment(), FragmentWithCustomTitle { class PurchaseFragment : Fragment(), FragmentWithCustomTitle {
@ -40,6 +43,8 @@ class PurchaseFragment : Fragment(), FragmentWithCustomTitle {
private const val PAGE_ERROR = 1 private const val PAGE_ERROR = 1
private const val PAGE_WAIT = 2 private const val PAGE_WAIT = 2
private const val PAGE_DONE = 3 private const val PAGE_DONE = 3
private const val PAGE_AUTH = 4
private const val PAGE_TOKEN = 5
} }
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
@ -49,22 +54,18 @@ class PurchaseFragment : Fragment(), FragmentWithCustomTitle {
activityModel.resetProcessPurchaseSuccess() activityModel.resetProcessPurchaseSuccess()
} }
model.retry(activityModel) model.init(activityModel, getActivityViewModel(requireActivity()))
} }
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
val binding = FragmentPurchaseBinding.inflate(inflater, container, false) val binding = FragmentPurchaseBinding.inflate(inflater, container, false)
var processingPurchaseError = false var processingPurchaseError = false
mergeLiveData(activityModel.status, model.status).observe(viewLifecycleOwner, { mergeLiveData(activityModel.status, model.status).observe(viewLifecycleOwner) { (activityStatus, fragmentStatus) ->
status ->
val (activityStatus, fragmentStatus) = status!!
if (fragmentStatus != null) { if (fragmentStatus != null) {
when (fragmentStatus) { when (fragmentStatus) {
PurchaseFragmentPreparing -> binding.flipper.displayedChild = PAGE_WAIT PurchaseModel.Status.Preparing -> binding.flipper.displayedChild = PAGE_WAIT
is PurchaseFragmentReady -> { is PurchaseModel.Status.ReadyRegular -> {
when (activityStatus) { when (activityStatus) {
null -> binding.flipper.displayedChild = PAGE_WAIT null -> binding.flipper.displayedChild = PAGE_WAIT
ActivityPurchaseModelStatus.Working -> binding.flipper.displayedChild = PAGE_WAIT ActivityPurchaseModelStatus.Working -> binding.flipper.displayedChild = PAGE_WAIT
@ -82,42 +83,55 @@ class PurchaseFragment : Fragment(), FragmentWithCustomTitle {
ActivityPurchaseModelStatus.Done -> binding.flipper.displayedChild = PAGE_DONE ActivityPurchaseModelStatus.Done -> binding.flipper.displayedChild = PAGE_DONE
}.let { } }.let { }
} }
is PurchaseFragmentError -> { is PurchaseModel.Status.Error -> {
binding.flipper.displayedChild = PAGE_ERROR binding.flipper.displayedChild = PAGE_ERROR
binding.errorReason = when (fragmentStatus) { binding.errorReason = when (fragmentStatus) {
PurchaseFragmentErrorBillingNotSupportedByDevice -> getString(R.string.purchase_error_not_supported_by_device) PurchaseModel.Status.Error.Unrecoverable.BillingNotSupportedByDevice -> getString(R.string.purchase_error_not_supported_by_device)
PurchaseFragmentErrorBillingNotSupportedByAppVariant -> getString(R.string.purchase_error_not_supported_by_app_variant) is PurchaseModel.Status.Error.Recoverable.NetworkError -> getString(R.string.error_network)
is PurchaseFragmentNetworkError -> getString(R.string.error_network) PurchaseModel.Status.Error.Unrecoverable.ExistingPaymentError -> getString(R.string.purchase_error_existing_payment)
PurchaseFragmentExistingPaymentError -> getString(R.string.purchase_error_existing_payment) PurchaseModel.Status.Error.Unrecoverable.ServerRejectedError -> getString(R.string.purchase_error_server_rejected)
PurchaseFragmentServerRejectedError -> getString(R.string.purchase_error_server_rejected) PurchaseModel.Status.Error.Unrecoverable.ServerClientCombinationUnsupported -> getString(R.string.purchase_error_server_different_key)
PurchaseFragmentServerHasDifferentPublicKey -> getString(R.string.purchase_error_server_different_key)
} }
binding.showRetryButton = when (fragmentStatus) { binding.showRetryButton = when (fragmentStatus) {
is PurchaseFragmentRecoverableError -> true is PurchaseModel.Status.Error.Recoverable -> true
is PurchaseFragmentUnrecoverableError -> false is PurchaseModel.Status.Error.Unrecoverable -> false
} }
binding.showErrorDetailsButton = when (fragmentStatus) { binding.showErrorDetailsButton = when (fragmentStatus) {
is PurchaseFragmentNetworkError -> true is PurchaseModel.Status.Error.Recoverable.NetworkError -> true
else -> false else -> false
} }
processingPurchaseError = false processingPurchaseError = false
} }
PurchaseModel.Status.WaitingForAuth -> {
binding.flipper.displayedChild = PAGE_AUTH
}
is PurchaseModel.Status.ReadyToken -> {
binding.flipper.displayedChild = PAGE_TOKEN
binding.purchaseToken = fragmentStatus.token
}
}.let { } }.let { }
} else { } else {
binding.flipper.displayedChild = PAGE_WAIT binding.flipper.displayedChild = PAGE_WAIT
} }
}) }
getActivityViewModel(requireActivity()).authenticatedUser.observe(viewLifecycleOwner) { user ->
if (user != null && model.status.value is PurchaseModel.Status.WaitingForAuth) {
model.retry()
}
}
binding.handlers = object: PurchaseFragmentHandlers { binding.handlers = object: PurchaseFragmentHandlers {
override fun retryAtErrorScreenClicked() { override fun retryAtErrorScreenClicked() {
if (processingPurchaseError) { if (processingPurchaseError) {
activityModel.queryAndProcessPurchasesAsync() activityModel.queryAndProcessPurchasesAsync()
} else { } else {
model.retry(activityModel) model.retry()
} }
} }
@ -125,7 +139,7 @@ class PurchaseFragment : Fragment(), FragmentWithCustomTitle {
val status = model.status.value val status = model.status.value
val exception = when (status) { val exception = when (status) {
is PurchaseFragmentNetworkError -> status.exception is PurchaseModel.Status.Error.Recoverable.NetworkError -> status.exception
else -> RuntimeException("other error") else -> RuntimeException("other error")
} }
@ -139,6 +153,18 @@ class PurchaseFragment : Fragment(), FragmentWithCustomTitle {
override fun buyForOneYear() { override fun buyForOneYear() {
activityModel.startPurchase(PurchaseIds.SKU_YEAR, checkAtBackend = true, activity = requireActivity()) activityModel.startPurchase(PurchaseIds.SKU_YEAR, checkAtBackend = true, activity = requireActivity())
} }
override fun showAuthDialog() {
(requireActivity() as ActivityViewModelHolder).showAuthenticationScreen()
}
override fun copyPurchaseTokenToClipboard() {
model.status.value?.also {
if (it is PurchaseModel.Status.ReadyToken) {
Clipboard.setAndToast(requireContext(), it.token)
}
}
}
} }
return binding.root return binding.root
@ -152,4 +178,6 @@ interface PurchaseFragmentHandlers {
fun showErrorDetails() fun showErrorDetails()
fun buyForOneMonth() fun buyForOneMonth()
fun buyForOneYear() fun buyForOneYear()
fun showAuthDialog()
fun copyPurchaseTokenToClipboard()
} }

View file

@ -25,77 +25,128 @@ import io.timelimit.android.extensions.BillingNotSupportedException
import io.timelimit.android.livedata.castDown import io.timelimit.android.livedata.castDown
import io.timelimit.android.logic.AppLogic import io.timelimit.android.logic.AppLogic
import io.timelimit.android.logic.DefaultAppLogic import io.timelimit.android.logic.DefaultAppLogic
import io.timelimit.android.sync.actions.apply.ApplyActionChildAddLimitAuthentication
import io.timelimit.android.sync.actions.apply.ApplyActionParentDeviceAuthentication
import io.timelimit.android.sync.actions.apply.ApplyActionParentPasswordAuthentication
import io.timelimit.android.sync.network.CanDoPurchaseStatus import io.timelimit.android.sync.network.CanDoPurchaseStatus
import io.timelimit.android.sync.network.api.NotFoundHttpError
import io.timelimit.android.ui.main.ActivityViewModel
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
class PurchaseModel(application: Application): AndroidViewModel(application) { class PurchaseModel(application: Application): AndroidViewModel(application) {
private val logic: AppLogic by lazy { DefaultAppLogic.with(application) } private val logic: AppLogic by lazy { DefaultAppLogic.with(application) }
private val statusInternal = MutableLiveData<PurchaseFragmentStatus>() private val statusInternal = MutableLiveData<Status>()
private val lock = Mutex() private val lock = Mutex()
private var activityPurchaseModel: ActivityPurchaseModel? = null
private var auth: ActivityViewModel? = null
val status = statusInternal.castDown() val status = statusInternal.castDown()
fun retry(activityPurchaseModel: ActivityPurchaseModel) { fun init(activityPurchaseModel: ActivityPurchaseModel, auth: ActivityViewModel) {
if (this.status.value is PurchaseFragmentRecoverableError || this.status.value == null) { this.activityPurchaseModel = activityPurchaseModel
prepare(activityPurchaseModel) this.auth = auth
retry()
}
fun retry() {
if (
this.status.value is Status.Error.Recoverable ||
this.status.value is Status.WaitingForAuth ||
this.status.value == null
) {
val activityPurchaseModel = activityPurchaseModel
val auth = auth
if (activityPurchaseModel != null && auth != null) {
prepare(activityPurchaseModel, auth)
}
} }
} }
private fun prepare(activityPurchaseModel: ActivityPurchaseModel) { private fun prepare(activityPurchaseModel: ActivityPurchaseModel, auth: ActivityViewModel) {
runAsync { runAsync {
lock.withLock { lock.withLock {
try { try {
statusInternal.value = PurchaseFragmentPreparing statusInternal.value = Status.Preparing
if (!BuildConfig.storeCompilant) {
statusInternal.value = PurchaseFragmentErrorBillingNotSupportedByAppVariant
} else {
val server = logic.serverLogic.getServerConfigCoroutine() val server = logic.serverLogic.getServerConfigCoroutine()
val canDoPurchase = if (server.hasAuthToken) suspend fun canDoPurchase() = if (server.hasAuthToken) server.api.canDoPurchase(server.deviceAuthToken)
server.api.canDoPurchase(server.deviceAuthToken) else CanDoPurchaseStatus.NoForUnknownReason
else
CanDoPurchaseStatus.NoForUnknownReason if (!BuildConfig.storeCompilant) {
if (auth.isParentAuthenticated()) {
val authData = auth.authenticatedUser.value?.first
try {
val token = when (authData) {
ApplyActionParentDeviceAuthentication -> server.api.createIdentityToken(
deviceAuthToken = server.deviceAuthToken,
parentUserId = "",
parentPasswordSecondHash = "device"
)
is ApplyActionParentPasswordAuthentication -> server.api.createIdentityToken(
deviceAuthToken = server.deviceAuthToken,
parentUserId = authData.parentUserId,
parentPasswordSecondHash = authData.secondPasswordHash
)
is ApplyActionChildAddLimitAuthentication -> throw RuntimeException(
"child can not do that"
)
null -> throw RuntimeException("missing user")
}
statusInternal.value = Status.ReadyToken(token)
} catch (ex: NotFoundHttpError) {
statusInternal.value = Status.Error.Unrecoverable.ServerClientCombinationUnsupported
}
} else statusInternal.value = Status.WaitingForAuth
} else {
val canDoPurchase = canDoPurchase()
if (canDoPurchase is CanDoPurchaseStatus.Yes) { if (canDoPurchase is CanDoPurchaseStatus.Yes) {
if (canDoPurchase.publicKey?.contentEquals(Base64.decode(BuildConfig.googlePlayKey, 0)) == false) { if (canDoPurchase.publicKey?.contentEquals(Base64.decode(BuildConfig.googlePlayKey, 0)) == false) {
statusInternal.value = PurchaseFragmentServerHasDifferentPublicKey statusInternal.value = Status.Error.Unrecoverable.ServerClientCombinationUnsupported
} else { } else {
val skus = activityPurchaseModel.queryProducts(PurchaseIds.BUY_SKUS) val skus = activityPurchaseModel.queryProducts(PurchaseIds.BUY_SKUS)
statusInternal.value = PurchaseFragmentReady( statusInternal.value = Status.ReadyRegular(
monthPrice = skus.find { it.productId == PurchaseIds.SKU_MONTH }?.oneTimePurchaseOfferDetails?.formattedPrice.toString(), monthPrice = skus.find { it.productId == PurchaseIds.SKU_MONTH }?.oneTimePurchaseOfferDetails?.formattedPrice.toString(),
yearPrice = skus.find { it.productId == PurchaseIds.SKU_YEAR }?.oneTimePurchaseOfferDetails?.formattedPrice.toString() yearPrice = skus.find { it.productId == PurchaseIds.SKU_YEAR }?.oneTimePurchaseOfferDetails?.formattedPrice.toString()
) )
} }
} else if (canDoPurchase == CanDoPurchaseStatus.NotDueToOldPurchase) { } else if (canDoPurchase == CanDoPurchaseStatus.NotDueToOldPurchase) {
statusInternal.value = PurchaseFragmentExistingPaymentError statusInternal.value = Status.Error.Unrecoverable.ExistingPaymentError
} else { } else {
statusInternal.value = PurchaseFragmentServerRejectedError statusInternal.value = Status.Error.Unrecoverable.ServerRejectedError
} }
} }
} catch (ex: BillingNotSupportedException) { } catch (ex: BillingNotSupportedException) {
statusInternal.value = PurchaseFragmentErrorBillingNotSupportedByDevice statusInternal.value = Status.Error.Unrecoverable.BillingNotSupportedByDevice
} catch (ex: Exception) { } catch (ex: Exception) {
statusInternal.value = PurchaseFragmentNetworkError(ex) statusInternal.value = Status.Error.Recoverable.NetworkError(ex)
} }
} }
} }
} }
sealed class Status {
sealed class Error: Status() {
sealed class Recoverable: Error() {
data class NetworkError(val exception: Exception): Recoverable()
}
sealed class Unrecoverable: Error() {
object BillingNotSupportedByDevice: Unrecoverable()
object ExistingPaymentError: Unrecoverable()
object ServerRejectedError: Unrecoverable()
object ServerClientCombinationUnsupported: Unrecoverable()
}
}
class ReadyRegular(val monthPrice: String, val yearPrice: String): Status()
class ReadyToken(val token: String): Status()
object Preparing: Status()
object WaitingForAuth: Status()
}
} }
sealed class PurchaseFragmentStatus
sealed class PurchaseFragmentError: PurchaseFragmentStatus()
class PurchaseFragmentReady(val monthPrice: String, val yearPrice: String): PurchaseFragmentStatus()
object PurchaseFragmentPreparing: PurchaseFragmentStatus()
sealed class PurchaseFragmentUnrecoverableError: PurchaseFragmentError()
sealed class PurchaseFragmentRecoverableError: PurchaseFragmentError()
object PurchaseFragmentErrorBillingNotSupportedByDevice: PurchaseFragmentUnrecoverableError()
object PurchaseFragmentErrorBillingNotSupportedByAppVariant: PurchaseFragmentUnrecoverableError()
data class PurchaseFragmentNetworkError(val exception: Exception): PurchaseFragmentRecoverableError()
object PurchaseFragmentExistingPaymentError: PurchaseFragmentUnrecoverableError()
object PurchaseFragmentServerRejectedError: PurchaseFragmentUnrecoverableError()
object PurchaseFragmentServerHasDifferentPublicKey: PurchaseFragmentUnrecoverableError()

View file

@ -0,0 +1,33 @@
/*
* TimeLimit Copyright <C> 2019 - 2022 Jonas Lochmann
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package io.timelimit.android.util
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.widget.Toast
import androidx.core.content.getSystemService
import io.timelimit.android.R
object Clipboard {
fun setAndToast(context: Context, content: String) {
context.getSystemService<ClipboardManager>()?.also { clipboard ->
clipboard.setPrimaryClip(ClipData.newPlainText("TimeLimit", content))
Toast.makeText(context, R.string.diagnose_sync_copied_to_clipboard, Toast.LENGTH_SHORT).show()
}
}
}

View file

@ -36,7 +36,11 @@
<variable <variable
name="priceData" name="priceData"
type="io.timelimit.android.ui.payment.PurchaseFragmentReady" /> type="io.timelimit.android.ui.payment.PurchaseModel.Status.ReadyRegular" />
<variable
name="purchaseToken"
type="String" />
<import type="android.view.View" /> <import type="android.view.View" />
</data> </data>
@ -196,5 +200,75 @@
</LinearLayout> </LinearLayout>
</RelativeLayout> </RelativeLayout>
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:padding="8dp"
android:orientation="vertical"
android:layout_centerVertical="true"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<TextView
android:textAppearance="?android:textAppearanceMedium"
android:gravity="center_horizontal"
android:text="@string/purchase_requires_auth_text"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<Button
android:layout_marginTop="4dp"
android:onClick="@{() -> handlers.showAuthDialog()}"
android:text="@string/add_user_authentication_required_btn"
android:layout_gravity="center_horizontal"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
</LinearLayout>
</RelativeLayout>
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:padding="8dp"
android:orientation="vertical"
android:layout_centerVertical="true"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<TextView
android:textAppearance="?android:textAppearanceMedium"
android:gravity="center_horizontal"
android:text="@string/purchase_token_explain"
android:autoLink="web"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<TextView
android:paddingTop="8dp"
android:paddingBottom="8dp"
android:textAppearance="?android:textAppearanceSmall"
android:layout_gravity="center_horizontal"
tools:text="12345678"
android:text="@{purchaseToken}"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
<Button
android:layout_marginTop="4dp"
android:onClick="@{() -> handlers.copyPurchaseTokenToClipboard()}"
android:text="@string/diagnose_sync_copy_to_clipboard"
android:layout_gravity="center_horizontal"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
</LinearLayout>
</RelativeLayout>
</io.timelimit.android.ui.view.SafeViewFlipper> </io.timelimit.android.ui.view.SafeViewFlipper>
</layout> </layout>

View file

@ -1293,11 +1293,17 @@
Sie haben bereits bezahlt. Sie können die Vollversion erst kurz vor dem Ablaufen (oder danach) verlängern Sie haben bereits bezahlt. Sie können die Vollversion erst kurz vor dem Ablaufen (oder danach) verlängern
</string> </string>
<string name="purchase_error_server_rejected">Der Server akzeptiert momentan keine Bezahlungen</string> <string name="purchase_error_server_rejected">Der Server akzeptiert momentan keine Bezahlungen</string>
<string name="purchase_error_server_different_key">Der Server unterstützt Bezahlungen, aber nicht über diesen App-Build</string> <string name="purchase_error_server_different_key">Der Server unterstützt möglicherweise Bezahlungen, aber nicht über diesen App-Build</string>
<string name="purchase_done_title">Bezahlung abgeschlossen</string> <string name="purchase_done_title">Bezahlung abgeschlossen</string>
<string name="purchase_done_text">Betätigen Sie die Zurück-Taste, um zur App zurückzukehren</string> <string name="purchase_done_text">Betätigen Sie die Zurück-Taste, um zur App zurückzukehren</string>
<string name="purchase_requires_auth_text">Sie müssen sich für den Kauf anmelden</string>
<string name="purchase_token_explain">Unter https://timelimit.io/de/#how-to-purchase erfahren Sie,
wie Sie den Kauf durchführen können.
Ihr Kauftoken lautet:
</string>
<string name="purchase_required_dialog_title">Vollversion erforderlich</string> <string name="purchase_required_dialog_title">Vollversion erforderlich</string>
<string name="purchase_required_dialog_text"> <string name="purchase_required_dialog_text">
Diese Funktion benötigt die Vollversion. Diese Funktion benötigt die Vollversion.

View file

@ -1335,11 +1335,16 @@
You can only extend the premium version if it expired or shortly before that. You can only extend the premium version if it expired or shortly before that.
</string> </string>
<string name="purchase_error_server_rejected">The server does not accept payments currently</string> <string name="purchase_error_server_rejected">The server does not accept payments currently</string>
<string name="purchase_error_server_different_key">The server does support payments, but not using this app build</string> <string name="purchase_error_server_different_key">The server maybe does support payments, but not using this app build</string>
<string name="purchase_done_title">Purchase succeeded</string> <string name="purchase_done_title">Purchase succeeded</string>
<string name="purchase_done_text">Press back to go back to the App</string> <string name="purchase_done_text">Press back to go back to the App</string>
<string name="purchase_requires_auth_text">You have to authenticate for the purchase</string>
<string name="purchase_token_explain">See https://timelimit.io/en/#how-to-purchase for details on the purchase.
Your purchase token is:
</string>
<string name="purchase_required_dialog_title">Premium version required</string> <string name="purchase_required_dialog_title">Premium version required</string>
<string name="purchase_required_dialog_text"> <string name="purchase_required_dialog_text">
The feature requires the premium version. The feature requires the premium version.