Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
610 changes: 303 additions & 307 deletions app/src/androidTest/java/page/ooooo/geoshare/ConversionBehaviorTest.kt

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ interface InputBehaviorTest : BehaviorTest {

fun UiAutomatorTestScope.testTextUri(expectedPoints: Points, unsafeText: String) {
// It would be preferable to test sharing of the text with the app, but this shell command doesn't work when
// there are spaces in the texts, so we put the text in the main screen of the app instead.
// there are spaces in the text. So instead, we type the text in the main form of the app.
// device.executeShellCommand(
// "am start -a android.intent.action.SEND -t text/plain -e android.intent.extra.TEXT $unsafeText -n ${BuildConfig.APPLICATION_ID}.debug/${BuildConfig.APPLICATION_ID}.ConversionActivity ${BuildConfig.APPLICATION_ID}.debug"
// )
Expand All @@ -63,6 +63,7 @@ interface InputBehaviorTest : BehaviorTest {
// Set main input
val mainInput = onElement { viewIdResourceName == "geoShareMainInputUriStringTextField" }
mainInput.setText(unsafeText)
quickWaitForStableInActiveWindow() // Wait for the submit button to get its final position, after setting text

// Submit main form
if (mainInput.isFocused) {
Expand Down
20 changes: 14 additions & 6 deletions app/src/demo/java/page/ooooo/geoshare/lib/billing/BillingImpl.kt
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ class BillingImpl(
context: Context,
private val resources: Resources = context.resources,
) : Billing {

@StringRes
override val appNameResId = R.string.app_name_pro
override val features = persistentListOf(AutomationFeature, CustomLinkFeature)
Expand All @@ -45,24 +44,33 @@ class BillingImpl(
_message.value = null
CoroutineScope(Dispatchers.Default).launch {
delay(2.seconds)
_status.value = BillingStatus.NotPurchased(pending = false)
_status.value = BillingStatus.NotPurchased()
}
}

override fun endConnection() {}

override suspend fun queryOffers(): BillingOffers = BillingOffers.Done(offers)

override fun consumePurchases() {
_status.value = BillingStatus.NotPurchased()
}

override suspend fun launchBillingFlow(activity: Activity, offerToken: String) {
_message.value = null
delay(1.seconds)
val product = offers.firstOrNull { offer -> offer.token == offerToken }?.let { offer ->
products.firstOrNull { product -> product.id == offer.productId }
}
if (product != null) {
_status.value = BillingStatus.NotPurchased(pending = true)
_status.value = BillingStatus.Pending()
delay(3.seconds)
_status.value = BillingStatus.Purchased(product, expired = false, refundable = true)
_status.value = BillingStatus.Purchased(
product,
expired = false,
refundable = true,
token = "demo_purchased",
)
} else {
_message.value = Message(resources.getString(R.string.billing_purchase_error_unknown), isError = true)
}
Expand All @@ -74,7 +82,7 @@ class BillingImpl(
BillingProduct.Type.DONATION -> {}

BillingProduct.Type.ONE_TIME -> {
_status.value = BillingStatus.NotPurchased(pending = false)
_status.value = BillingStatus.NotPurchased()
}

BillingProduct.Type.SUBSCRIPTION -> {
Expand All @@ -83,7 +91,7 @@ class BillingImpl(
?.takeUnless { it.expired }
?.copy(expired = true)
// If the status is expired, make it not purchased
?: BillingStatus.NotPurchased(pending = false)
?: BillingStatus.NotPurchased()
}
}
}
Expand Down
11 changes: 10 additions & 1 deletion app/src/free/java/page/ooooo/geoshare/lib/billing/BillingImpl.kt
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,14 @@ class BillingImpl(@Suppress("unused") context: Context) : Billing {
override val products = persistentListOf(product)
override val refundableDuration = Duration.ZERO

override val status = flowOf(BillingStatus.Purchased(product, expired = false, refundable = false))
override val status = flowOf(
BillingStatus.Purchased(
product,
expired = false,
refundable = false,
token = "free_purchased",
)
)
.stateIn(
CoroutineScope(Dispatchers.Default),
SharingStarted.WhileSubscribed(5000),
Expand All @@ -42,6 +49,8 @@ class BillingImpl(@Suppress("unused") context: Context) : Billing {

override suspend fun queryOffers(): BillingOffers = BillingOffers.Done(persistentListOf())

override fun consumePurchases() {}

override suspend fun launchBillingFlow(activity: Activity, offerToken: String) {}

override fun manageProduct(activity: Activity, product: BillingProduct) {}
Expand Down
4 changes: 4 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@
<action android:name="android.intent.action.VIEW" />
<data android:mimeType="application/gpx+xml" />
</intent>
<intent>
<action android:name="android.intent.action.SENDTO" />
<data android:scheme="mailto" />
</intent>
</queries>

<uses-permission android:name="android.permission.INTERNET" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ class DefaultUserPreferencesRepository @Inject constructor(
UserPreferencesValues(
automation = AutomationPreference.getValue(it),
automationDelay = AutomationDelayPreference.getValue(it),
billingCachedProductId = BillingCachedProductIdPreference.getValue(it),
cachedPurchase = CachedPurchasePreference.getValue(it),
changelogShownForVersionCode = ChangelogShownForVersionCodePreference.getValue(it),
connectionPermission = ConnectionPermissionPreference.getValue(it),
coordinateFormat = CoordinateFormatPreference.getValue(it),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ class FakeUserPreferencesRepository @Inject constructor() : UserPreferencesRepos
val defaultFakeUserPreferences = UserPreferencesValues(
automation = NoopAutomation,
automationDelay = 5.seconds,
billingCachedProductId = "",
cachedPurchase = null,
changelogShownForVersionCode = 22,
connectionPermission = Permission.ALWAYS,
coordinateFormat = CoordinateFormat.DEC,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package page.ooooo.geoshare.data.local.preferences

import kotlinx.serialization.Serializable

@Serializable
data class CachedPurchase(val productId: String, val token: String)
Original file line number Diff line number Diff line change
Expand Up @@ -27,32 +27,39 @@ interface TextPreference<T> : UserPreference<T> {
val key: Preferences.Key<String>
val default: T

fun serialize(value: T): String
fun serialize(value: T, log: ILog = DefaultLog): String

fun deserialize(value: String?): T
fun deserialize(value: String?, log: ILog = DefaultLog): T

fun isValid(value: String?): Boolean = true

override fun getValue(preferences: Preferences, log: ILog): T = deserialize(preferences[key])
override fun getValue(preferences: Preferences, log: ILog): T =
deserialize(preferences[key], log)

override fun setValue(preferences: MutablePreferences, value: T, log: ILog) = preferences.set(key, serialize(value))
override fun setValue(preferences: MutablePreferences, value: T, log: ILog) =
preferences.set(key, serialize(value, log))
}

interface NullableIntPreference : TextPreference<Int?> {
override fun serialize(value: Int?) = value.toString()
override fun serialize(value: Int?, log: ILog) =
value.toString()

override fun deserialize(value: String?) = value?.toIntOrNull() ?: default
override fun deserialize(value: String?, log: ILog) =
value?.toIntOrNull() ?: default
}

interface DurationPreference : TextPreference<Duration> {
val minSec: Int
val maxSec: Int

override fun serialize(value: Duration) = value.toInt(DurationUnit.SECONDS).toString()
override fun serialize(value: Duration, log: ILog) =
value.toInt(DurationUnit.SECONDS).toString()

override fun deserialize(value: String?) = value?.toIntOrNull()?.coerceIn(minSec, maxSec)?.seconds ?: default
override fun deserialize(value: String?, log: ILog) =
value?.toIntOrNull()?.coerceIn(minSec, maxSec)?.seconds ?: default

override fun isValid(value: String?) = value?.toIntOrNull()?.let { it in minSec..maxSec } == true
override fun isValid(value: String?) =
value?.toIntOrNull()?.let { it in minSec..maxSec } == true
}

interface OptionsPreference<T> : UserPreference<T> {
Expand Down Expand Up @@ -268,45 +275,60 @@ object AutomationDelayPreference : DurationPreference {
override fun getValue(values: UserPreferencesValues) = values.automationDelay
}

object BillingCachedProductIdPreference : TextPreference<String?> {
override val key = stringPreferencesKey("billing_product_id")
override val default = ""
object CachedPurchasePreference : TextPreference<CachedPurchase?> {
override val key = stringPreferencesKey("cached_purchase")
override val default = null
val loading = default

override fun serialize(value: String?) = value.orEmpty()
override fun serialize(value: CachedPurchase?, log: ILog) =
try {
Json.encodeToString(value)
} catch (tr: SerializationException) {
// Silently ignore serialization errors, because the value should always serialize
log.e(TAG, "Serialization error", tr)
""
}

override fun deserialize(value: String?, log: ILog) =
value?.let {
try {
Json.decodeFromString<CachedPurchase?>(it)
} catch (tr: IllegalArgumentException) {
log.e(TAG, "Deserialization error", tr)
null
}
}

override fun deserialize(value: String?) = value?.ifEmpty { null }
override fun getValue(values: UserPreferencesValues) = values.cachedPurchase

override fun getValue(values: UserPreferencesValues) = values.billingCachedProductId
private const val TAG = "BillingCachedProductPreference"
}

/**
* A set of strings stored as a JSON array.
*/
interface SetPreference : UserPreference<Set<String>?> {
val key: Preferences.Key<String>
val default: Set<String>?

override fun getValue(preferences: Preferences, log: ILog): Set<String>? {
val serializedString = preferences[key] ?: return default
return try {
Json.decodeFromString<Set<String>?>(serializedString)
} catch (tr: IllegalArgumentException) {
log.e(TAG, "Deserialization error", tr)
null
}
}
interface SetPreference : TextPreference<Set<String>?> {
override val key: Preferences.Key<String>
override val default: Set<String>?

override fun setValue(preferences: MutablePreferences, value: Set<String>?, log: ILog) {
val serializedString = try {
override fun serialize(value: Set<String>?, log: ILog) =
try {
Json.encodeToString(value)
} catch (tr: SerializationException) {
// Silently ignore serialization errors, because a set of strings should always serialize
// Silently ignore serialization errors, because the value should always serialize
log.e(TAG, "Serialization error", tr)
return
""
}

override fun deserialize(value: String?, log: ILog) =
value?.let {
try {
Json.decodeFromString<Set<String>?>(it)
} catch (tr: IllegalArgumentException) {
log.e(TAG, "Deserialization error", tr)
null
}
}
preferences[key] = serializedString
}

companion object {
private const val TAG = "SetPreference"
Expand Down Expand Up @@ -342,7 +364,7 @@ object ChangelogShownForVersionCodePreference : NullableIntPreference {
data class UserPreferencesValues(
val automation: Automation = AutomationPreference.loading,
val automationDelay: Duration = AutomationDelayPreference.loading,
val billingCachedProductId: String? = BillingCachedProductIdPreference.loading,
val cachedPurchase: CachedPurchase? = CachedPurchasePreference.loading,
val changelogShownForVersionCode: Int? = ChangelogShownForVersionCodePreference.loading,
val connectionPermission: Permission = ConnectionPermissionPreference.loading,
val coordinateFormat: CoordinateFormat = CoordinateFormatPreference.loading,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,15 @@ object AndroidTools {
fun openWebUri(context: Context, uriString: String): Boolean =
startActivity(context, Intent(Intent.ACTION_VIEW, uriString.toUri()))

private fun createEmailIntent(address: String): Intent =
Intent(Intent.ACTION_SENDTO, "mailto:$address".toUri())

fun composeEmail(context: Context, address: String): Boolean =
startActivity(context, createEmailIntent(address))

fun hasEmailApp(context: Context): Boolean =
createEmailIntent("").resolveActivity(context.packageManager) != null

fun hasLocationPermission(context: Context): Boolean =
context.checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED ||
context.checkSelfPermission(Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
Expand Down
2 changes: 2 additions & 0 deletions app/src/main/java/page/ooooo/geoshare/lib/billing/Billing.kt
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ interface Billing {

suspend fun queryOffers(): BillingOffers

fun consumePurchases()

suspend fun launchBillingFlow(activity: Activity, offerToken: String)

fun manageProduct(activity: Activity, product: BillingProduct)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,15 @@ import androidx.compose.runtime.Immutable
sealed interface BillingStatus {
class Loading : BillingStatus

data class NotPurchased(
val pending: Boolean,
) : BillingStatus
class Pending : BillingStatus

class NotPurchased : BillingStatus

@Immutable
data class Purchased(
val product: BillingProduct,
val expired: Boolean,
val refundable: Boolean,
val token: String,
) : BillingStatus
}
9 changes: 0 additions & 9 deletions app/src/main/java/page/ooooo/geoshare/lib/billing/Features.kt
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import page.ooooo.geoshare.R
sealed interface Feature {
val titleResId: Int
val descriptionResId: Int
val itemsResIds: List<Int>
}

object AutomationFeature : Feature {
Expand All @@ -15,12 +14,6 @@ object AutomationFeature : Feature {

@StringRes
override val descriptionResId = R.string.billing_feature_automation_description

override val itemsResIds = listOf(
R.string.billing_feature_automation_item_open_app,
R.string.billing_feature_automation_item_navigate,
R.string.billing_feature_automation_item_copy,
)
}

object CustomLinkFeature : Feature {
Expand All @@ -29,6 +22,4 @@ object CustomLinkFeature : Feature {

@StringRes
override val descriptionResId = R.string.billing_feature_custom_link_description

override val itemsResIds = emptyList<Int>()
}
Loading
Loading