diff --git a/client/android/app/src/main/AndroidManifest.xml b/client/android/app/src/main/AndroidManifest.xml index bcccbaf04..88d119eb1 100644 --- a/client/android/app/src/main/AndroidManifest.xml +++ b/client/android/app/src/main/AndroidManifest.xml @@ -67,6 +67,7 @@ + - diff --git a/client/android/app/src/main/java/com/liketrek/trek/TrekSharedFiles.java b/client/android/app/src/main/java/com/liketrek/trek/TrekSharedFiles.java index 7974b33c8..e22bd4450 100644 --- a/client/android/app/src/main/java/com/liketrek/trek/TrekSharedFiles.java +++ b/client/android/app/src/main/java/com/liketrek/trek/TrekSharedFiles.java @@ -46,11 +46,12 @@ final class TrekSharedFiles { Uri uri = null; // A view intent also brings sign-in callbacks and widget links; only a file is a document. if (Intent.ACTION_VIEW.equals(intent.getAction()) && intent.getData() != null - && ("content".equals(intent.getData().getScheme()) || "file".equals(intent.getData().getScheme()))) { + && "content".equals(intent.getData().getScheme())) { uri = intent.getData(); } else if (Intent.ACTION_SEND.equals(intent.getAction())) uri = intent.getParcelableExtra(Intent.EXTRA_STREAM); - if (uri == null) return; + // content:// only: a file:// path would be read with TREK's own rights. + if (uri == null || !"content".equals(uri.getScheme())) return; try (InputStream in = context.getContentResolver().openInputStream(uri)) { if (in == null) return; ByteArrayOutputStream out = new ByteArrayOutputStream(); diff --git a/client/android/app/src/main/java/com/liketrek/trek/TrekShellPlugin.java b/client/android/app/src/main/java/com/liketrek/trek/TrekShellPlugin.java index 371630f71..4058222b6 100644 --- a/client/android/app/src/main/java/com/liketrek/trek/TrekShellPlugin.java +++ b/client/android/app/src/main/java/com/liketrek/trek/TrekShellPlugin.java @@ -94,6 +94,14 @@ public class TrekShellPlugin extends Plugin { @Override public void load() { TrekSharedFiles.setListener(file -> notifyListeners("sharedFile", file)); + // A reloaded page starts without a stream; whatever the old one ran is stopped. + if (location != null) location.stop(); + locationActive = false; + // A pending purchase that cleared while the shell runs: redeemed like a restore. + com.liketrek.trek.billing.TrekBilling.INSTANCE.setOnTransaction(receipt -> { + notifyListeners("storeTransaction", receipt); + return kotlin.Unit.INSTANCE; + }); com.liketrek.trek.screen.TrekKeyCommands.INSTANCE.setOnPress(id -> { JSObject pressed = new JSObject(); pressed.put("id", id); @@ -284,6 +292,8 @@ public class TrekShellPlugin extends Plugin { /** The server URL is part of the bridge's configuration, so it takes a new activity. */ private void restart() { + locationActive = false; + if (location != null) location.stop(); TrekServerStore.markRestart(getContext()); getActivity().runOnUiThread(() -> getActivity().recreate()); } @@ -336,6 +346,7 @@ public class TrekShellPlugin extends Plugin { @Override protected void handleOnResume() { super.handleOnResume(); + if (locationActive && location != null && locationAllowed()) location.start(locationHighAccuracy); if (!authBrowserOpen) return; authBrowserOpen = false; handler.postDelayed(() -> { @@ -468,23 +479,40 @@ public class TrekShellPlugin extends Plugin { return location; } - /** One location stream for the UI's navigator.geolocation, asking for the permission the first time. */ + /** Whether the app may know where the device is: precisely, or "Approximate" only. */ + private boolean locationAllowed() { + return androidx.core.content.ContextCompat.checkSelfPermission(getContext(), Manifest.permission.ACCESS_FINE_LOCATION) + == android.content.pm.PackageManager.PERMISSION_GRANTED + || androidx.core.content.ContextCompat.checkSelfPermission(getContext(), Manifest.permission.ACCESS_COARSE_LOCATION) + == android.content.pm.PackageManager.PERMISSION_GRANTED; + } + + /** True while the UI has a stream running, so it resumes after the app was in the background. */ + private boolean locationActive; + + /** + * One location stream for the UI's navigator.geolocation, asking for the + * permission the first time. "Approximate" counts as allowed; the stream then + * runs on the coarse fixes Android gives. + */ @PluginMethod public void startLocation(PluginCall call) { locationHighAccuracy = Boolean.TRUE.equals(call.getBoolean("highAccuracy", false)); - if (getPermissionState("location") != PermissionState.GRANTED) { + if (!locationAllowed()) { requestPermissionForAlias("location", call, "locationPermissionAnswered"); return; } + locationActive = true; location().start(locationHighAccuracy); call.resolve(); } @PermissionCallback private void locationPermissionAnswered(PluginCall call) { - if (getPermissionState("location") != PermissionState.GRANTED) { + if (!locationAllowed()) { location().emitError(1, "Location permission denied"); } else { + locationActive = true; location().start(locationHighAccuracy); } call.resolve(); @@ -492,10 +520,26 @@ public class TrekShellPlugin extends Plugin { @PluginMethod public void stopLocation(PluginCall call) { + locationActive = false; if (location != null) location.stop(); call.resolve(); } + /** No location in the background, as in a browser: paused with the app, resumed with it. */ + @Override + protected void handleOnPause() { + super.handleOnPause(); + if (location != null) location.stop(); + } + + /** The page and the activity are gone: nothing can stop the stream any more but this. */ + @Override + protected void handleOnDestroy() { + locationActive = false; + if (location != null) location.stop(); + super.handleOnDestroy(); + } + // ---- Map tile store ---- @PluginMethod @@ -538,6 +582,12 @@ public class TrekShellPlugin extends Plugin { com.liketrek.trek.billing.TrekBilling.INSTANCE.restore(getContext(), call); } + /** A paid subscription never delivered (it cleared while the app was closed), for the shell to redeem. */ + @PluginMethod + public void storeUnfinished(PluginCall call) { + com.liketrek.trek.billing.TrekBilling.INSTANCE.unfinished(getContext(), call); + } + // ---- Google Wallet ---- /** The only links this hands on: Google Wallet's save-to-wallet page. */ @@ -577,6 +627,7 @@ public class TrekShellPlugin extends Plugin { /** Feature windowLayout: the window's size classes and a foldable's posture and hinge. */ @PluginMethod public void getWindowLayout(PluginCall call) { - getActivity().runOnUiThread(() -> call.resolve(com.liketrek.trek.screen.TrekWindowLayout.INSTANCE.describe(getActivity(), null))); + // The last layout WindowManager reported, posture and hinge included; the bare window before the first. + getActivity().runOnUiThread(() -> call.resolve(com.liketrek.trek.screen.TrekWindowLayout.INSTANCE.current(getActivity()))); } } diff --git a/client/android/app/src/main/java/com/liketrek/trek/billing/TrekBilling.kt b/client/android/app/src/main/java/com/liketrek/trek/billing/TrekBilling.kt index 13c1e87a8..ed35e018d 100644 --- a/client/android/app/src/main/java/com/liketrek/trek/billing/TrekBilling.kt +++ b/client/android/app/src/main/java/com/liketrek/trek/billing/TrekBilling.kt @@ -41,6 +41,14 @@ object TrekBilling { private val listener = PurchasesUpdatedListener { result, purchases -> onPurchasesUpdated(result, purchases) } + /** + * Told of a purchase that went through outside a purchase sheet: a payment + * that was pending (cash, some carrier billing) and cleared later. The shell + * redeems it like a restore (the "storeTransaction" event), since Play refunds + * what is not acknowledged within three days. + */ + var onTransaction: ((JSObject) -> Unit)? = null + private fun client(context: Context): BillingClient = client ?: BillingClient.newBuilder(context.applicationContext) .setListener(listener) .enablePendingPurchases(PendingPurchasesParams.newBuilder().enableOneTimeProducts().enablePrepaidPlans().build()) @@ -134,7 +142,14 @@ object TrekBilling { } private fun onPurchasesUpdated(result: BillingResult, purchases: List?) { - if (pendingPurchase == null) return // A renewal or a pending one going through later: a restore picks it up. + if (pendingPurchase == null) { + // No sheet waiting: a pending purchase that cleared, or a renewal. Only an + // undelivered one matters; renewals are acknowledged already. + if (result.responseCode == BillingClient.BillingResponseCode.OK) { + undelivered(purchases.orEmpty(), PREFIX)?.let { onTransaction?.invoke(receipt(it)) } + } + return + } when (result.responseCode) { BillingClient.BillingResponseCode.OK -> { val purchase = purchases?.firstOrNull { pendingProduct in it.products } ?: purchases?.firstOrNull() @@ -145,12 +160,29 @@ object TrekBilling { } } BillingClient.BillingResponseCode.USER_CANCELED -> settle("cancelled", null) - // Already subscribed on this Google account: the shell offers the restore. - BillingClient.BillingResponseCode.ITEM_ALREADY_OWNED -> settle("failed", null) + // Already subscribed on this Google account (a reinstall, another phone, a + // redeem that never happened): that subscription is the purchase, and the + // shell redeems it as it would a new one. + BillingClient.BillingResponseCode.ITEM_ALREADY_OWNED -> settleOwned() else -> settle("failed", null) } } + private fun settleOwned() { + val call = pendingPurchase ?: return + val params = QueryPurchasesParams.newBuilder().setProductType(BillingClient.ProductType.SUBS).build() + client?.queryPurchasesAsync(params) { result, purchases -> + val owned = if (result.responseCode == BillingClient.BillingResponseCode.OK) newest(purchases, PREFIX) else null + if (pendingPurchase !== call) return@queryPurchasesAsync + settle(if (owned != null) "bought" else "failed", owned) + } ?: settle("failed", null) + } + + private fun receipt(purchase: Purchase): JSObject = JSObject() + .put("purchaseToken", purchase.purchaseToken) + .put("transactionId", purchase.purchaseToken) + .put("productId", purchase.products.firstOrNull { it.startsWith(PREFIX) } ?: purchase.products.firstOrNull()) + private fun settle(kind: String, purchase: Purchase?) { val call = pendingPurchase ?: return pendingPurchase = null @@ -195,6 +227,27 @@ object TrekBilling { } } + /** + * A paid cloud purchase that was never delivered (not acknowledged), for the + * shell to redeem at its start: one that cleared while the app was closed. + */ + fun unfinished(context: Context, call: PluginCall) { + val prefix = call.getString("prefix") ?: PREFIX + ready(context) { ok -> + if (!ok) return@ready call.resolve(JSObject()) + val params = QueryPurchasesParams.newBuilder().setProductType(BillingClient.ProductType.SUBS).build() + client(context).queryPurchasesAsync(params) { result, purchases -> + val found = if (result.responseCode == BillingClient.BillingResponseCode.OK) undelivered(purchases, prefix) else null + call.resolve(found?.let(::receipt) ?: JSObject()) + } + } + } + + internal fun undelivered(purchases: List, prefix: String): Purchase? = + newest(purchases.filter { !it.isAcknowledged }, prefix) + + private const val PREFIX = "cloud." + internal fun newest(purchases: List, prefix: String): Purchase? = purchases .filter { p -> p.purchaseState == Purchase.PurchaseState.PURCHASED && p.products.any { it.startsWith(prefix) } } .maxByOrNull { it.purchaseTime } diff --git a/client/android/app/src/main/java/com/liketrek/trek/screen/TrekWindowLayout.kt b/client/android/app/src/main/java/com/liketrek/trek/screen/TrekWindowLayout.kt index 831b85ae6..ebe57efc7 100644 --- a/client/android/app/src/main/java/com/liketrek/trek/screen/TrekWindowLayout.kt +++ b/client/android/app/src/main/java/com/liketrek/trek/screen/TrekWindowLayout.kt @@ -25,16 +25,18 @@ import kotlinx.coroutines.launch * are in CSS pixels, relative to the window. */ object TrekWindowLayout { - @Volatile var latest: JSObject = JSObject() - private set + /** The last WindowLayoutInfo WindowManager reported, with the fold in it. */ + @Volatile private var lastInfo: WindowLayoutInfo? = null + + /** The window now, with the posture last reported: what getWindowLayout answers. */ + fun current(activity: Activity): JSObject = describe(activity, lastInfo) fun observe(activity: ComponentActivity, emit: (JSObject) -> Unit) { - latest = describe(activity, null) activity.lifecycleScope.launch { activity.repeatOnLifecycle(Lifecycle.State.STARTED) { WindowInfoTracker.getOrCreate(activity).windowLayoutInfo(activity).collect { info -> - latest = describe(activity, info) - emit(latest) + lastInfo = info + emit(describe(activity, info)) } } } diff --git a/client/android/app/src/main/java/com/liketrek/trek/share/ShareActivity.kt b/client/android/app/src/main/java/com/liketrek/trek/share/ShareActivity.kt index 6409ce459..c149d3dcd 100644 --- a/client/android/app/src/main/java/com/liketrek/trek/share/ShareActivity.kt +++ b/client/android/app/src/main/java/com/liketrek/trek/share/ShareActivity.kt @@ -121,7 +121,12 @@ class ShareActivity : ComponentActivity() { super.onCreate(savedInstanceState) trips = ShareHandoff.trips(this) val shortcutTrip = intent.getStringExtra(ShortcutManagerCompat.EXTRA_SHORTCUT_ID)?.removePrefix("trip.")?.toLongOrNull() - selected = shortcutTrip?.takeIf { id -> trips.any { it.id == id } } ?: trips.firstOrNull()?.id + selected = if (savedInstanceState?.containsKey(SELECTED) == true) { + // The trip picked before the system recreated the sheet; -1 is "choose later". + savedInstanceState.getLong(SELECTED).takeIf { it >= 0 } + } else { + shortcutTrip?.takeIf { id -> trips.any { it.id == id } } ?: trips.firstOrNull()?.id + } val incoming = intent lifecycleScope.launch { phase = try { @@ -140,6 +145,11 @@ class ShareActivity : ComponentActivity() { } } + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + outState.putLong(SELECTED, selected ?: -1L) + } + private fun add() { val current = item ?: return if (phase != Phase.Ready) return @@ -414,4 +424,8 @@ class ShareActivity : ComponentActivity() { ShareItem.Kind.IMAGE -> R.string.share_kind_image ShareItem.Kind.FILE -> R.string.share_kind_file } + + private companion object { + const val SELECTED = "trek.share.selected" + } } diff --git a/client/android/app/src/main/java/com/liketrek/trek/share/ShareItem.kt b/client/android/app/src/main/java/com/liketrek/trek/share/ShareItem.kt index 40f068fcf..279dcffc7 100644 --- a/client/android/app/src/main/java/com/liketrek/trek/share/ShareItem.kt +++ b/client/android/app/src/main/java/com/liketrek/trek/share/ShareItem.kt @@ -52,12 +52,19 @@ object ShareLoader { throw ShareLoadException(ShareLoadException.Reason.UNSUPPORTED) } + /** + * The shared document, content:// only. The share target is open to every app, + * and a file:// path would be read with TREK's rights, its own private files + * included; every sender today shares through a content provider anyway. + */ private fun streamOf(intent: Intent): Uri? = when (intent.action) { - Intent.ACTION_VIEW -> intent.data?.takeIf { it.scheme == ContentResolver.SCHEME_CONTENT || it.scheme == ContentResolver.SCHEME_FILE } + Intent.ACTION_VIEW -> intent.data Intent.ACTION_SEND -> parcelable(intent, Intent.EXTRA_STREAM) Intent.ACTION_SEND_MULTIPLE -> parcelableList(intent)?.firstOrNull() else -> null - } + }?.takeIf(::isShareable) + + internal fun isShareable(uri: Uri): Boolean = uri.scheme == ContentResolver.SCHEME_CONTENT @Suppress("DEPRECATION") private fun parcelable(intent: Intent, key: String): Uri? = diff --git a/client/android/app/src/main/java/com/liketrek/trek/tiles/TrekTileStore.kt b/client/android/app/src/main/java/com/liketrek/trek/tiles/TrekTileStore.kt index 08c7c327a..54cc837d8 100644 --- a/client/android/app/src/main/java/com/liketrek/trek/tiles/TrekTileStore.kt +++ b/client/android/app/src/main/java/com/liketrek/trek/tiles/TrekTileStore.kt @@ -76,7 +76,9 @@ object TrekTileStore { val connection = URL(url.toString()).openConnection() as HttpURLConnection connection.connectTimeout = TIMEOUT_MS connection.readTimeout = TIMEOUT_MS - request.requestHeaders["Accept"]?.let { connection.setRequestProperty("Accept", it) } + // The WebView's own headers go along: Mapbox checks a URL-restricted token + // against the Referer, and the server's origin is what the token allows. + for ((name, value) in forwardedHeaders(request.requestHeaders)) connection.setRequestProperty(name, value) try { val code = connection.responseCode if (code == 200) { @@ -86,11 +88,9 @@ object TrekTileStore { store(body, meta, bytes, type, now) return response(bytes, type) } - } else { - // 401, 404 and the like belong to the map, which reports them itself. - val error = connection.errorStream?.use { it.readBytes() } ?: ByteArray(0) - return WebResourceResponse(connection.contentType ?: "text/plain", null, code, connection.responseMessage ?: "Error", cors(), ByteArrayInputStream(error)) } + // Anything but a tile (401/403 for a token, 404, a 5xx) the WebView asks for + // itself, with everything a browser sends, and the map reports what it gets. } finally { connection.disconnect() } @@ -101,6 +101,12 @@ object TrekTileStore { } /** HttpURLConnection has already undone any gzip, so no Content-Encoding goes along. */ + /** Headers that are the request's own; the connection sets the hop-by-hop ones itself. */ + private val HOP_BY_HOP = setOf("connection", "keep-alive", "transfer-encoding", "te", "upgrade", "host", "content-length", "accept-encoding") + + internal fun forwardedHeaders(headers: Map?): Map = + headers.orEmpty().filterKeys { it.lowercase() !in HOP_BY_HOP } + private fun response(bytes: ByteArray, type: String): WebResourceResponse { val mime = type.substringBefore(';').trim() val charset = Regex("charset=([^;]+)").find(type)?.groupValues?.get(1) diff --git a/client/android/app/src/main/java/com/liketrek/trek/widget/TrekWidgetReceivers.kt b/client/android/app/src/main/java/com/liketrek/trek/widget/TrekWidgetReceivers.kt index 10a9b6805..b3e69c518 100644 --- a/client/android/app/src/main/java/com/liketrek/trek/widget/TrekWidgetReceivers.kt +++ b/client/android/app/src/main/java/com/liketrek/trek/widget/TrekWidgetReceivers.kt @@ -62,6 +62,14 @@ object TrekWidgetUpdater { CollabWidgetReceiver::class, ) + /** The widget a placed widget id belongs to, by its provider. */ + fun widgetFor(context: Context, appWidgetId: Int): GlanceAppWidget? { + val provider = android.appwidget.AppWidgetManager.getInstance(context).getAppWidgetInfo(appWidgetId)?.provider?.className + ?: return null + val receiver = receivers.firstOrNull { it.java.name == provider } ?: return null + return receiver.java.getDeclaredConstructor().newInstance().glanceAppWidget + } + fun widgets(): List = listOf(TripWidget(), TodayWidget(), BookingsWidget(), TodosWidget(), PackingWidget(), AtlasWidget(), CollabWidget()) diff --git a/client/android/app/src/main/java/com/liketrek/trek/widget/TrekWidgets.kt b/client/android/app/src/main/java/com/liketrek/trek/widget/TrekWidgets.kt index e23a80075..97b5cd2be 100644 --- a/client/android/app/src/main/java/com/liketrek/trek/widget/TrekWidgets.kt +++ b/client/android/app/src/main/java/com/liketrek/trek/widget/TrekWidgets.kt @@ -10,6 +10,9 @@ import androidx.compose.material.icons.rounded.Luggage import androidx.compose.material.icons.rounded.Place import androidx.compose.material.icons.rounded.Public import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -75,9 +78,16 @@ abstract class TrekWidget : GlanceAppWidget() { override suspend fun provideGlance(context: Context, id: GlanceId) { val appWidgetId = runCatching { GlanceAppWidgetManager(context).getAppWidgetId(id) }.getOrDefault(-1) - val tripId = if (perTrip && appWidgetId >= 0) WidgetStore.tripFor(context, appWidgetId) else WidgetStore.AUTO - val snapshot = WidgetStore.load(context, tripId) - provideContent { TrekWidgetTheme.Themed { Content(snapshot) } } + provideContent { + // Read inside the composition: a session outlives one update, and every + // write (the dashboard's, then each trip's) must reach it. + val version by WidgetStore.version.collectAsState() + val snapshot = remember(version) { + val tripId = if (perTrip && appWidgetId >= 0) WidgetStore.tripFor(context, appWidgetId) else WidgetStore.AUTO + WidgetStore.load(context, tripId) + } + TrekWidgetTheme.Themed { Content(snapshot) } + } } override suspend fun providePreview(context: Context, widgetCategory: Int) { diff --git a/client/android/app/src/main/java/com/liketrek/trek/widget/WidgetConfigActivity.kt b/client/android/app/src/main/java/com/liketrek/trek/widget/WidgetConfigActivity.kt index 010afa23b..6b2a4d3c5 100644 --- a/client/android/app/src/main/java/com/liketrek/trek/widget/WidgetConfigActivity.kt +++ b/client/android/app/src/main/java/com/liketrek/trek/widget/WidgetConfigActivity.kt @@ -84,9 +84,10 @@ class WidgetConfigActivity : ComponentActivity() { lifecycleScope.launch { val manager = GlanceAppWidgetManager(this@WidgetConfigActivity) val glanceId = runCatching { manager.getGlanceIdBy(appWidgetId) }.getOrNull() - if (glanceId != null) { - for (widget in TrekWidgetUpdater.widgets()) runCatching { widget.update(this@WidgetConfigActivity, glanceId) } - } + // Only the widget this id belongs to: Glance keys a session by the id alone, + // so another class updating it would draw its own layout there. + val widget = TrekWidgetUpdater.widgetFor(this@WidgetConfigActivity, appWidgetId) + if (glanceId != null && widget != null) runCatching { widget.update(this@WidgetConfigActivity, glanceId) } setResult(RESULT_OK, Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)) finish() } diff --git a/client/android/app/src/main/java/com/liketrek/trek/widget/WidgetStore.kt b/client/android/app/src/main/java/com/liketrek/trek/widget/WidgetStore.kt index 6232f3a9a..519384d86 100644 --- a/client/android/app/src/main/java/com/liketrek/trek/widget/WidgetStore.kt +++ b/client/android/app/src/main/java/com/liketrek/trek/widget/WidgetStore.kt @@ -2,6 +2,8 @@ package com.liketrek.trek.widget import android.content.Context import java.io.File +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow /** * Where the widgets' snapshots live: one JSON file per key in the app's own @@ -14,6 +16,15 @@ object WidgetStore { private const val PREFS = "trek.widgets" const val AUTO = "auto" + private val changes = MutableStateFlow(0L) + + /** + * Counts every write and every trip picked for a widget. A widget reads its + * data inside its composition, keyed on this, so a write that lands while its + * Glance session still runs is drawn too, not only the first one. + */ + val version: StateFlow = changes + fun isValidKey(key: String): Boolean = KEY.matches(key) private fun dir(context: Context): File = File(context.filesDir, "widgets").apply { mkdirs() } @@ -27,6 +38,7 @@ object WidgetStore { target.delete() temp.renameTo(target) } + changes.value++ } fun read(context: Context, key: String): String? { @@ -56,7 +68,8 @@ object WidgetStore { context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getString("trip.$appWidgetId", AUTO) ?: AUTO fun setTripFor(context: Context, appWidgetId: Int, tripId: String) { - context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().putString("trip.$appWidgetId", tripId).apply() + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().putString("trip.$appWidgetId", tripId).commit() + changes.value++ } fun forget(context: Context, appWidgetIds: IntArray) { diff --git a/client/android/app/src/main/java/com/liketrek/trek/widget/WidgetUi.kt b/client/android/app/src/main/java/com/liketrek/trek/widget/WidgetUi.kt index 955d13ffd..f395fdd0b 100644 --- a/client/android/app/src/main/java/com/liketrek/trek/widget/WidgetUi.kt +++ b/client/android/app/src/main/java/com/liketrek/trek/widget/WidgetUi.kt @@ -252,7 +252,7 @@ object RelativeDay { return DateTimeFormatter.ofPattern(DateFormat.getBestDateTimePattern(locale, skeleton), locale).format(date) } - /** "1 – 9 Oct" in the device's own way of writing a range. */ + /** "1-9 Oct" in the device's own way of writing a range. */ fun range(context: Context, start: String, end: String): String? { val from = WidgetSnapshot.day(start) ?: return null val to = WidgetSnapshot.day(end) ?: return null diff --git a/client/android/app/src/test/java/com/liketrek/trek/NativeLogicTest.kt b/client/android/app/src/test/java/com/liketrek/trek/NativeLogicTest.kt index 2c6280e44..d2bfebf56 100644 --- a/client/android/app/src/test/java/com/liketrek/trek/NativeLogicTest.kt +++ b/client/android/app/src/test/java/com/liketrek/trek/NativeLogicTest.kt @@ -8,6 +8,7 @@ import com.liketrek.trek.screen.TrekWindowLayout import com.liketrek.trek.share.ShareHandoff import com.liketrek.trek.share.ShareItem import com.liketrek.trek.share.ShareLoader +import com.liketrek.trek.tiles.TrekTileStore import java.time.LocalDate import org.json.JSONArray import org.json.JSONObject @@ -115,4 +116,46 @@ class NativeLogicTest { assertEquals("token-cloud.plus.yearly-2000", found?.purchaseToken) assertNull(TrekBilling.newest(emptyList(), "cloud.")) } + + @Test + fun mapboxRequestsKeepTheWebViewsHeaders() { + val forwarded = TrekTileStore.forwardedHeaders( + mapOf( + "Referer" to "https://trek.example.com/", + "Origin" to "https://trek.example.com", + "User-Agent" to "Mozilla/5.0 (Linux; Android 16; wv)", + "Accept" to "application/x-protobuf", + "Accept-Encoding" to "gzip", + "Connection" to "keep-alive", + "Host" to "api.mapbox.com", + ), + ) + // A URL-restricted token is checked against the Referer, so it has to go along. + assertEquals("https://trek.example.com/", forwarded["Referer"]) + assertEquals("https://trek.example.com", forwarded["Origin"]) + assertEquals("Mozilla/5.0 (Linux; Android 16; wv)", forwarded["User-Agent"]) + assertEquals("application/x-protobuf", forwarded["Accept"]) + assertNull(forwarded["Accept-Encoding"]) + assertNull(forwarded["Connection"]) + assertNull(forwarded["Host"]) + assertEquals(emptyMap(), TrekTileStore.forwardedHeaders(null)) + } + + @Test + fun aPaymentThatClearedLaterIsTheUndeliveredOne() { + fun purchase(product: String, time: Long, acknowledged: Boolean) = Purchase( + JSONObject() + .put("productIds", JSONArray().put(product)) + .put("purchaseTime", time) + .put("purchaseState", 0) + .put("acknowledged", acknowledged) + .put("purchaseToken", "token-$time") + .toString(), + "sig", + ) + val renewed = purchase("cloud.plus.yearly", 3000, acknowledged = true) + val cleared = purchase("cloud.start.monthly", 2000, acknowledged = false) + assertEquals("token-2000", TrekBilling.undelivered(listOf(renewed, cleared), "cloud.")?.purchaseToken) + assertNull(TrekBilling.undelivered(listOf(renewed), "cloud.")) + } }