Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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}")
Expand All @@ -293,4 +291,4 @@ class RealPebbleDeepLinkHandler(
return token
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -254,13 +254,20 @@ interface PebbleWebServices {
suspend fun fetchAppStoreHome(type: AppType, hardwarePlatform: WatchType?, enabledOnly: Boolean, useCache: Boolean): List<AppStoreHomeResult>
suspend fun fetchPebbleAppStoreHomes(hardwarePlatform: WatchType?, useCache: Boolean): Map<AppType, AppStoreHomeResult?>
suspend fun searchAppStore(search: String, appType: AppType, watchType: WatchType, page: Int = 0, pageSize: Int = 20): List<Pair<AppstoreSource, StoreSearchResult>>
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
suspend fun fetchUserHearts()
suspend fun getWeather(latitude: Double, longitude: Double, units: WeatherUnit, language: String): WeatherResponse?
}

data class AppstoreDeepLinkResolution(
val appId: String,
val source: AppstoreSource,
val duplicateSources: List<AppstoreSource>,
)

class RealPebbleWebServices(
private val httpClient: PebbleHttpClient,
private val firmwareUpdateCheck: FirmwareUpdateCheck,
Expand Down Expand Up @@ -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<Pair<AppstoreSource, StoreSearchResult>> {
// val params = SearchMethodParams()
val sources = getAllSources()
Expand Down Expand Up @@ -464,6 +509,40 @@ class RealPebbleWebServices(
}
}

internal fun appstoreDeepLinkSources(
sources: List<AppstoreSource>,
sourceHint: String?,
): List<AppstoreSource> {
val candidates = sourceHint
?.takeIf { it.isNotBlank() }
?.let { hint -> sources.filter { it.matchesAppstoreDeepLinkHint(hint) } }
?: sources

return candidates.sortedWith(
compareBy<AppstoreSource> { 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,
Expand Down Expand Up @@ -1012,4 +1091,4 @@ fun StoreApplication.toLockerEntry(sourceUrl: String, timelineToken: String?): L
},
source = app.source,
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -245,6 +246,11 @@ private fun fakePebbleModule(appContext: AppContext) = module {
pageSize: Int
): List<Pair<AppstoreSource, StoreSearchResult>> = emptyList()

override suspend fun resolveAppstoreDeepLink(
appId: String,
sourceHint: String?
): AppstoreDeepLinkResolution? = null

override suspend fun addToLegacyLockerWithResponse(uuid: String): LockerAddResponse? = null

override suspend fun addToLocker(
Expand Down
Original file line number Diff line number Diff line change
@@ -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())
}
}