diff --git a/pebble/src/commonMain/kotlin/coredevices/pebble/PebbleDeepLinkHandler.kt b/pebble/src/commonMain/kotlin/coredevices/pebble/PebbleDeepLinkHandler.kt index 96462d1b..4b557b26 100644 --- a/pebble/src/commonMain/kotlin/coredevices/pebble/PebbleDeepLinkHandler.kt +++ b/pebble/src/commonMain/kotlin/coredevices/pebble/PebbleDeepLinkHandler.kt @@ -3,11 +3,11 @@ package coredevices.pebble import co.touchlab.kermit.Logger import com.eygraber.uri.Uri import coredevices.analytics.CoreAnalytics -import coredevices.database.AppstoreSourceDao import coredevices.libindex.device.IndexPlatformBluetoothAssociations import coredevices.libindex.device.REQUEST_URI_HOST import coredevices.pebble.account.PebbleAccount import coredevices.pebble.firmware.FirmwareUpdateUiTracker +import coredevices.pebble.services.PebbleWebServices import coredevices.pebble.ui.NavBarRoute import coredevices.pebble.ui.PebbleNavBarRoutes import io.rebble.libpebblecommon.connection.AppContext @@ -22,7 +22,6 @@ import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.launch import kotlinx.io.files.Path @@ -50,7 +49,7 @@ class RealPebbleDeepLinkHandler( private val libPebble: LibPebble, private val analytics: CoreAnalytics, private val context: AppContext, - private val appstoreSourceDao: AppstoreSourceDao, + private val pebbleWebServices: PebbleWebServices, private val firmwareUpdateUiTracker: FirmwareUpdateUiTracker, ) : PebbleDeepLinkHandler { private val logger = Logger.withTag("PebbleDeepLinkHandler") @@ -78,7 +77,7 @@ class RealPebbleDeepLinkHandler( uri.scheme == "pebble" -> { when (uri.host) { CUSTOM_BOOT_CONFIG_URL -> handleBootConfig(uri.path) - STORE_URL -> handleAppstore("https://appstore-api.rebble.io/api", uri.path) + STORE_URL -> handleAppstore(uri.path, uri.getQueryParameter(SOURCE_QUERY_PARAM)) NAVBAR_URL -> handleNavbar(uri.path) REGISTER_INDEX_COMPANION_HOST -> handleRegisterIndexCompanion() SHOW_WATCHES_HOST -> handleShowWatches(uri.path) @@ -200,24 +199,22 @@ class RealPebbleDeepLinkHandler( return true } - private fun handleAppstore(storeUrl: String, path: String?): Boolean { + private fun handleAppstore(path: String?, sourceHint: String?): Boolean { if (path == null) { return false } logger.v { "handleAppstore: $path" } GlobalScope.launch { val appId = path.removePrefix("/").removeSuffix("/") - val store = appstoreSourceDao.getAllEnabledSourcesFlow().firstOrNull()?.find { - it.url == storeUrl - } - if (store == null) { - _snackBarMessages.tryEmit("Failed to find app in enabled feeds") + val resolved = pebbleWebServices.resolveAppstoreDeepLink(appId, sourceHint) + if (resolved == null) { + _snackBarMessages.tryEmit("App not found in appstore sources") return@launch } val route = PebbleNavBarRoutes.LockerAppRoute( uuid = null, storedId = appId, - storeSource = store.id, + storeSource = resolved.source.id, ) _navigateToPebbleDeepLink.value = PebbleDeepLink(route) } @@ -269,10 +266,11 @@ class RealPebbleDeepLinkHandler( companion object { private const val CUSTOM_BOOT_CONFIG_URL: String = "custom-boot-config-url" private const val STORE_URL: String = "appstore" + private const val SOURCE_QUERY_PARAM: String = "source" private const val NAVBAR_URL: String = "navbar" private val SHOW_WATCHES_HOST = "show-watches" // private val UPDATE_WATCH_NOW_HOST = "update-watch-now" - private val REGISTER_INDEX_COMPANION_HOST = IndexPlatformBluetoothAssociations.REQUEST_URI_HOST + private val REGISTER_INDEX_COMPANION_HOST = IndexPlatformBluetoothAssociations.Companion.REQUEST_URI_HOST val NOTIFICATION_INTENT_URI_SHOW_WATCHES = Uri.parse("pebble://${SHOW_WATCHES_HOST}") val NOTIFICATION_INTENT_URI_REGISTER_INDEX_COMPANION = Uri.parse("pebble://${REGISTER_INDEX_COMPANION_HOST}") // val NOTIFICATION_INTENT_URI_UPDATE_NOW = Uri.parse("pebble://${UPDATE_WATCH_NOW_HOST}") @@ -293,4 +291,4 @@ class RealPebbleDeepLinkHandler( return token } } -} \ No newline at end of file +} diff --git a/pebble/src/commonMain/kotlin/coredevices/pebble/services/PebbleWebServices.kt b/pebble/src/commonMain/kotlin/coredevices/pebble/services/PebbleWebServices.kt index e5fcb5ba..6b2cf50f 100644 --- a/pebble/src/commonMain/kotlin/coredevices/pebble/services/PebbleWebServices.kt +++ b/pebble/src/commonMain/kotlin/coredevices/pebble/services/PebbleWebServices.kt @@ -254,6 +254,7 @@ interface PebbleWebServices { suspend fun fetchAppStoreHome(type: AppType, hardwarePlatform: WatchType?, enabledOnly: Boolean, useCache: Boolean): List suspend fun fetchPebbleAppStoreHomes(hardwarePlatform: WatchType?, useCache: Boolean): Map suspend fun searchAppStore(search: String, appType: AppType, watchType: WatchType, page: Int = 0, pageSize: Int = 20): List> + suspend fun resolveAppstoreDeepLink(appId: String, sourceHint: String? = null): AppstoreDeepLinkResolution? suspend fun addToLegacyLockerWithResponse(uuid: String): LockerAddResponse? suspend fun addToLocker(entry: CommonAppType.Store, timelineToken: String?): Boolean suspend fun removeFromLegacyLocker(id: Uuid): Boolean @@ -261,6 +262,12 @@ interface PebbleWebServices { suspend fun getWeather(latitude: Double, longitude: Double, units: WeatherUnit, language: String): WeatherResponse? } +data class AppstoreDeepLinkResolution( + val appId: String, + val source: AppstoreSource, + val duplicateSources: List, +) + class RealPebbleWebServices( private val httpClient: PebbleHttpClient, private val firmwareUpdateCheck: FirmwareUpdateCheck, @@ -429,6 +436,44 @@ class RealPebbleWebServices( }.awaitAll().filterNotNull() } + override suspend fun resolveAppstoreDeepLink( + appId: String, + sourceHint: String?, + ): AppstoreDeepLinkResolution? { + val sources = appstoreDeepLinkSources( + sources = getAllSources(enabledOnly = false), + sourceHint = sourceHint, + ) + if (sources.isEmpty()) { + logger.w { "No appstore sources matched deep link source hint: $sourceHint" } + return null + } + val matches = coroutineScope { + sources.map { source -> + async { + val found = appstoreServiceForSource(source) + .fetchAppStoreApp(appId, hardwarePlatform = null, useCache = true) + ?.data + ?.isNotEmpty() == true + if (found) source else null + } + }.awaitAll().filterNotNull() + } + val source = matches.firstOrNull() ?: return null + if (matches.size > 1) { + logger.w { + "Appstore deep link id $appId matched multiple sources: " + + matches.joinToString { it.url } + + ". Using ${source.url}" + } + } + return AppstoreDeepLinkResolution( + appId = appId, + source = source, + duplicateSources = matches.drop(1), + ) + } + override suspend fun searchAppStore(search: String, appType: AppType, watchType: WatchType, page: Int, pageSize: Int): List> { // val params = SearchMethodParams() val sources = getAllSources() @@ -464,6 +509,40 @@ class RealPebbleWebServices( } } +internal fun appstoreDeepLinkSources( + sources: List, + sourceHint: String?, +): List { + val candidates = sourceHint + ?.takeIf { it.isNotBlank() } + ?.let { hint -> sources.filter { it.matchesAppstoreDeepLinkHint(hint) } } + ?: sources + + return candidates.sortedWith( + compareBy { source -> + when (source.url.normalizedAppstoreSourceUrl()) { + PEBBLE_FEED_URL -> 0 + REBBLE_FEED_URL -> 1 + else -> 2 + } + }.thenBy { it.id } + ) +} + +private fun AppstoreSource.matchesAppstoreDeepLinkHint(hint: String): Boolean { + val normalizedHint = hint.trim().trimEnd('/').lowercase() + val targetUrl = when (normalizedHint) { + "pebble", "repebble" -> PEBBLE_FEED_URL + "rebble" -> REBBLE_FEED_URL + else -> normalizedHint + }.normalizedAppstoreSourceUrl() + + return url.normalizedAppstoreSourceUrl() == targetUrl || + title.trim().lowercase() == normalizedHint +} + +private fun String.normalizedAppstoreSourceUrl(): String = trim().trimEnd('/').lowercase() + data class AppStoreHomeResult( val source: AppstoreSource, val result: AppStoreHome, @@ -1012,4 +1091,4 @@ fun StoreApplication.toLockerEntry(sourceUrl: String, timelineToken: String?): L }, source = app.source, ) -} \ No newline at end of file +} diff --git a/pebble/src/commonMain/kotlin/coredevices/pebble/ui/PreviewUtil.kt b/pebble/src/commonMain/kotlin/coredevices/pebble/ui/PreviewUtil.kt index 79326e50..a64ad35e 100644 --- a/pebble/src/commonMain/kotlin/coredevices/pebble/ui/PreviewUtil.kt +++ b/pebble/src/commonMain/kotlin/coredevices/pebble/ui/PreviewUtil.kt @@ -40,6 +40,7 @@ import coredevices.pebble.firmware.FirmwareUpdateUiTracker import coredevices.pebble.services.AppStoreHome import coredevices.pebble.services.AppStoreHomeResult import coredevices.pebble.services.AppstoreCache +import coredevices.pebble.services.AppstoreDeepLinkResolution import coredevices.pebble.services.CoreUsersMe import coredevices.pebble.services.PebbleWebServices import coredevices.pebble.services.StoreAppResponse @@ -245,6 +246,11 @@ private fun fakePebbleModule(appContext: AppContext) = module { pageSize: Int ): List> = emptyList() + override suspend fun resolveAppstoreDeepLink( + appId: String, + sourceHint: String? + ): AppstoreDeepLinkResolution? = null + override suspend fun addToLegacyLockerWithResponse(uuid: String): LockerAddResponse? = null override suspend fun addToLocker( diff --git a/pebble/src/commonTest/kotlin/coredevices/pebble/services/AppstoreDeepLinkSourcesTest.kt b/pebble/src/commonTest/kotlin/coredevices/pebble/services/AppstoreDeepLinkSourcesTest.kt new file mode 100644 index 00000000..5d6ef928 --- /dev/null +++ b/pebble/src/commonTest/kotlin/coredevices/pebble/services/AppstoreDeepLinkSourcesTest.kt @@ -0,0 +1,74 @@ +package coredevices.pebble.services + +import coredevices.database.AppstoreSource +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class AppstoreDeepLinkSourcesTest { + private val custom = AppstoreSource( + id = 9, + url = "https://example.com/appstore/api", + title = "Custom Store", + ) + private val pebble = AppstoreSource( + id = 2, + url = PEBBLE_FEED_URL, + title = "Pebble App Store", + ) + private val rebble = AppstoreSource( + id = 1, + url = REBBLE_FEED_URL, + title = "Rebble App Store", + ) + + @Test + fun ordersBuiltInSourcesBeforeCustomSources() { + val ordered = appstoreDeepLinkSources( + sources = listOf(custom, rebble, pebble), + sourceHint = null, + ) + + assertEquals(listOf(pebble, rebble, custom), ordered) + } + + @Test + fun pebbleHintOnlyReturnsPebbleSource() { + val ordered = appstoreDeepLinkSources( + sources = listOf(custom, rebble, pebble), + sourceHint = "pebble", + ) + + assertEquals(listOf(pebble), ordered) + } + + @Test + fun rebbleHintOnlyReturnsRebbleSource() { + val ordered = appstoreDeepLinkSources( + sources = listOf(custom, rebble, pebble), + sourceHint = "rebble", + ) + + assertEquals(listOf(rebble), ordered) + } + + @Test + fun exactUrlHintCanReturnCustomSource() { + val ordered = appstoreDeepLinkSources( + sources = listOf(custom, rebble, pebble), + sourceHint = "${custom.url}/", + ) + + assertEquals(listOf(custom), ordered) + } + + @Test + fun unmatchedHintReturnsNoSources() { + val ordered = appstoreDeepLinkSources( + sources = listOf(custom, rebble, pebble), + sourceHint = "missing", + ) + + assertTrue(ordered.isEmpty()) + } +}