fix(android): review findings on the tile store, widgets, location, billing and the share sheet

- Tile store: the WebView's headers go along (a URL-restricted Mapbox token
  is checked against the Referer), and anything but a tile is left to the
  WebView, so a 401/403 never comes from the store.
- Widgets read their snapshot inside the composition, so every write reaches
  a running Glance session; the trip picker updates only the widget the id
  belongs to.
- Location: Approximate counts as allowed; the stream pauses with the app,
  resumes with it, and stops when the page reloads or the activity goes.
- Billing: an already-owned subscription comes back as the purchase, so the
  shell redeems it; a payment that clears outside the sheet is announced
  (storeTransaction) and found again at start (storeUnfinished).
- getWindowLayout answers with the last posture and hinge, not the bare window.
- Share sheet: keeps the picked trip through rotation and folding, and takes
  content:// only, never a file:// path read with TREK's own rights.
This commit is contained in:
Maurice
2026-11-07 14:57:50 +01:00
parent 46f4cab1b7
commit ec22642f38
14 changed files with 241 additions and 31 deletions
@@ -67,6 +67,7 @@
<activity
android:name=".share.ShareActivity"
android:theme="@style/AppTheme.Sheet"
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden|navigation"
android:excludeFromRecents="true"
android:taskAffinity="com.liketrek.trek.share"
android:launchMode="standard"
@@ -91,8 +92,8 @@
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<!-- content:// only: a file:// path would be read with TREK's own rights. -->
<data android:scheme="content" />
<data android:scheme="file" />
<data android:mimeType="application/pdf" />
<data android:mimeType="text/calendar" />
<data android:mimeType="application/vnd.apple.pkpass" />
@@ -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();
@@ -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())));
}
}
@@ -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<Purchase>?) {
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<Purchase>, prefix: String): Purchase? =
newest(purchases.filter { !it.isAcknowledged }, prefix)
private const val PREFIX = "cloud."
internal fun newest(purchases: List<Purchase>, prefix: String): Purchase? = purchases
.filter { p -> p.purchaseState == Purchase.PurchaseState.PURCHASED && p.products.any { it.startsWith(prefix) } }
.maxByOrNull { it.purchaseTime }
@@ -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))
}
}
}
@@ -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"
}
}
@@ -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? =
@@ -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<String, String>?): Map<String, String> =
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)
@@ -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<GlanceAppWidget> =
listOf(TripWidget(), TodayWidget(), BookingsWidget(), TodosWidget(), PackingWidget(), AtlasWidget(), CollabWidget())
@@ -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) {
@@ -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()
}
@@ -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<Long> = 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) {
@@ -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
@@ -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<String, String>(), 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."))
}
}