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
* 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 removeDevice(deviceAuthToken: String, parentUserId: String, parentPasswordSecondHash: String, deviceId: String)
suspend fun isDeviceRemoved(deviceAuthToken: String): Boolean
suspend fun createIdentityToken(deviceAuthToken: String, parentUserId: String, parentPasswordSecondHash: String): String
}
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
* 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 {
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
* 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
* it under the terms of the GNU General Public License as published by
@ -16,16 +16,13 @@
package io.timelimit.android.ui.diagnose
import android.app.Dialog
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.os.Bundle
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.FragmentManager
import io.timelimit.android.R
import io.timelimit.android.extensions.showSafe
import io.timelimit.android.util.Clipboard
import java.io.PrintWriter
import java.io.StringWriter
@ -51,16 +48,11 @@ class DiagnoseExceptionDialogFragment: DialogFragment() {
}
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val message = getStackTraceString(arguments!!.getSerializable(EXCEPTION) as Exception)
val clipboard = context!!.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val message = getStackTraceString(requireArguments().getSerializable(EXCEPTION) as Exception)
return AlertDialog.Builder(context!!, theme)
return AlertDialog.Builder(requireContext(), theme)
.setMessage(message)
.setNeutralButton(R.string.diagnose_sync_copy_to_clipboard) { _, _ ->
clipboard.setPrimaryClip(ClipData.newPlainText("TimeLimit", message))
Toast.makeText(context, R.string.diagnose_sync_copied_to_clipboard, Toast.LENGTH_SHORT).show()
}
.setNeutralButton(R.string.diagnose_sync_copy_to_clipboard) { _, _ -> Clipboard.setAndToast(requireContext(), message) }
.setPositiveButton(R.string.generic_ok, null)
.create()
}

View file

@ -28,7 +28,10 @@ import io.timelimit.android.livedata.liveDataFromNullableValue
import io.timelimit.android.livedata.mergeLiveData
import io.timelimit.android.ui.MainActivity
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.getActivityViewModel
import io.timelimit.android.util.Clipboard
import java.lang.RuntimeException
class PurchaseFragment : Fragment(), FragmentWithCustomTitle {
@ -40,6 +43,8 @@ class PurchaseFragment : Fragment(), FragmentWithCustomTitle {
private const val PAGE_ERROR = 1
private const val PAGE_WAIT = 2
private const val PAGE_DONE = 3
private const val PAGE_AUTH = 4
private const val PAGE_TOKEN = 5
}
override fun onCreate(savedInstanceState: Bundle?) {
@ -49,22 +54,18 @@ class PurchaseFragment : Fragment(), FragmentWithCustomTitle {
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)
var processingPurchaseError = false
mergeLiveData(activityModel.status, model.status).observe(viewLifecycleOwner, {
status ->
val (activityStatus, fragmentStatus) = status!!
mergeLiveData(activityModel.status, model.status).observe(viewLifecycleOwner) { (activityStatus, fragmentStatus) ->
if (fragmentStatus != null) {
when (fragmentStatus) {
PurchaseFragmentPreparing -> binding.flipper.displayedChild = PAGE_WAIT
is PurchaseFragmentReady -> {
PurchaseModel.Status.Preparing -> binding.flipper.displayedChild = PAGE_WAIT
is PurchaseModel.Status.ReadyRegular -> {
when (activityStatus) {
null -> 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
}.let { }
}
is PurchaseFragmentError -> {
is PurchaseModel.Status.Error -> {
binding.flipper.displayedChild = PAGE_ERROR
binding.errorReason = when (fragmentStatus) {
PurchaseFragmentErrorBillingNotSupportedByDevice -> getString(R.string.purchase_error_not_supported_by_device)
PurchaseFragmentErrorBillingNotSupportedByAppVariant -> getString(R.string.purchase_error_not_supported_by_app_variant)
is PurchaseFragmentNetworkError -> getString(R.string.error_network)
PurchaseFragmentExistingPaymentError -> getString(R.string.purchase_error_existing_payment)
PurchaseFragmentServerRejectedError -> getString(R.string.purchase_error_server_rejected)
PurchaseFragmentServerHasDifferentPublicKey -> getString(R.string.purchase_error_server_different_key)
PurchaseModel.Status.Error.Unrecoverable.BillingNotSupportedByDevice -> getString(R.string.purchase_error_not_supported_by_device)
is PurchaseModel.Status.Error.Recoverable.NetworkError -> getString(R.string.error_network)
PurchaseModel.Status.Error.Unrecoverable.ExistingPaymentError -> getString(R.string.purchase_error_existing_payment)
PurchaseModel.Status.Error.Unrecoverable.ServerRejectedError -> getString(R.string.purchase_error_server_rejected)
PurchaseModel.Status.Error.Unrecoverable.ServerClientCombinationUnsupported -> getString(R.string.purchase_error_server_different_key)
}
binding.showRetryButton = when (fragmentStatus) {
is PurchaseFragmentRecoverableError -> true
is PurchaseFragmentUnrecoverableError -> false
is PurchaseModel.Status.Error.Recoverable -> true
is PurchaseModel.Status.Error.Unrecoverable -> false
}
binding.showErrorDetailsButton = when (fragmentStatus) {
is PurchaseFragmentNetworkError -> true
is PurchaseModel.Status.Error.Recoverable.NetworkError -> true
else -> 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 { }
} else {
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 {
override fun retryAtErrorScreenClicked() {
if (processingPurchaseError) {
activityModel.queryAndProcessPurchasesAsync()
} else {
model.retry(activityModel)
model.retry()
}
}
@ -125,7 +139,7 @@ class PurchaseFragment : Fragment(), FragmentWithCustomTitle {
val status = model.status.value
val exception = when (status) {
is PurchaseFragmentNetworkError -> status.exception
is PurchaseModel.Status.Error.Recoverable.NetworkError -> status.exception
else -> RuntimeException("other error")
}
@ -139,6 +153,18 @@ class PurchaseFragment : Fragment(), FragmentWithCustomTitle {
override fun buyForOneYear() {
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
@ -152,4 +178,6 @@ interface PurchaseFragmentHandlers {
fun showErrorDetails()
fun buyForOneMonth()
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.logic.AppLogic
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.api.NotFoundHttpError
import io.timelimit.android.ui.main.ActivityViewModel
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
class PurchaseModel(application: Application): AndroidViewModel(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 var activityPurchaseModel: ActivityPurchaseModel? = null
private var auth: ActivityViewModel? = null
val status = statusInternal.castDown()
fun retry(activityPurchaseModel: ActivityPurchaseModel) {
if (this.status.value is PurchaseFragmentRecoverableError || this.status.value == null) {
prepare(activityPurchaseModel)
fun init(activityPurchaseModel: ActivityPurchaseModel, auth: ActivityViewModel) {
this.activityPurchaseModel = 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 {
lock.withLock {
try {
statusInternal.value = PurchaseFragmentPreparing
statusInternal.value = Status.Preparing
if (!BuildConfig.storeCompilant) {
statusInternal.value = PurchaseFragmentErrorBillingNotSupportedByAppVariant
} else {
val server = logic.serverLogic.getServerConfigCoroutine()
val canDoPurchase = if (server.hasAuthToken)
server.api.canDoPurchase(server.deviceAuthToken)
else
CanDoPurchaseStatus.NoForUnknownReason
suspend fun canDoPurchase() = if (server.hasAuthToken) server.api.canDoPurchase(server.deviceAuthToken)
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.publicKey?.contentEquals(Base64.decode(BuildConfig.googlePlayKey, 0)) == false) {
statusInternal.value = PurchaseFragmentServerHasDifferentPublicKey
statusInternal.value = Status.Error.Unrecoverable.ServerClientCombinationUnsupported
} else {
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(),
yearPrice = skus.find { it.productId == PurchaseIds.SKU_YEAR }?.oneTimePurchaseOfferDetails?.formattedPrice.toString()
)
}
} else if (canDoPurchase == CanDoPurchaseStatus.NotDueToOldPurchase) {
statusInternal.value = PurchaseFragmentExistingPaymentError
statusInternal.value = Status.Error.Unrecoverable.ExistingPaymentError
} else {
statusInternal.value = PurchaseFragmentServerRejectedError
statusInternal.value = Status.Error.Unrecoverable.ServerRejectedError
}
}
} catch (ex: BillingNotSupportedException) {
statusInternal.value = PurchaseFragmentErrorBillingNotSupportedByDevice
statusInternal.value = Status.Error.Unrecoverable.BillingNotSupportedByDevice
} catch (ex: Exception) {
statusInternal.value = PurchaseFragmentNetworkError(ex)
}
statusInternal.value = Status.Error.Recoverable.NetworkError(ex)
}
}
}
}
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()
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()
}
}

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
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" />
</data>
@ -196,5 +200,75 @@
</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_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>
</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
</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_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_text">
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.
</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_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_text">
The feature requires the premium version.