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
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ 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.REBBLE_FEED_URL
import coredevices.pebble.ui.NavBarRoute
import coredevices.pebble.ui.PebbleNavBarRoutes
import io.rebble.libpebblecommon.connection.AppContext
Expand Down Expand Up @@ -102,7 +103,11 @@ 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.getQueryParameter(STORE_SOURCE_PARAM) ?: REBBLE_FEED_URL,
uri.path,
)
ADD_STORE_FEED_HOST -> handleAddStoreFeed(uri)
NAVBAR_URL -> handleNavbar(uri.path)
REGISTER_INDEX_COMPANION_HOST -> handleRegisterIndexCompanion()
SHOW_WATCHES_HOST -> handleShowWatches(uri.path)
Expand Down Expand Up @@ -315,6 +320,15 @@ class RealPebbleDeepLinkHandler(
return true
}

private fun handleAddStoreFeed(uri: Uri): Boolean {
val route = parseAddStoreFeedFrom(uri) ?: run {
logger.w { "handleAddStoreFeed: expected pebble://$ADD_STORE_FEED_HOST/{name}/{url}" }
return false
}
_navigateToPebbleDeepLink.value = PebbleDeepLink(route)
return true
}

private fun handleShowWatches(path: String?): Boolean {
if (path != null) {
firmwareUpdateUiTracker.updateWatchNow(libPebble, path.removePrefix("/").removeSuffix("/"))
Expand Down Expand Up @@ -362,6 +376,8 @@ class RealPebbleDeepLinkHandler(
private const val RESERVED_SIDELOAD_PREFIX = "pending_sideload_"
private const val CUSTOM_BOOT_CONFIG_URL: String = "custom-boot-config-url"
private const val STORE_URL: String = "appstore"
private const val STORE_SOURCE_PARAM: String = "source"
private const val ADD_STORE_FEED_HOST: String = "add-store-feed"
private const val NAVBAR_URL: String = "navbar"
private val SHOW_WATCHES_HOST = "show-watches"
// private val UPDATE_WATCH_NOW_HOST = "update-watch-now"
Expand All @@ -376,6 +392,18 @@ class RealPebbleDeepLinkHandler(

fun updateNowUri(identifier: PebbleIdentifier): Uri = Uri.parse("pebble://${SHOW_WATCHES_HOST}/${identifier.asString}")

/** `pebble://add-store-feed/{name}/{url}`, both segments percent-encoded. */
internal fun parseAddStoreFeedFrom(uri: Uri): PebbleNavBarRoutes.AppstoreSettingsRoute? {
val segments = uri.pathSegments
if (segments.size != 2) return null
val (name, url) = segments
if (name.isBlank() || url.isBlank()) return null
return PebbleNavBarRoutes.AppstoreSettingsRoute(
addSourceName = name,
addSourceUrl = url,
)
}

internal fun parseTokenFrom(path: String?): String? {
if (path == null) {
logger.w("handleBootConfig: path is null")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -669,11 +669,10 @@ class AppstoreService(
}

fun enableByDefault(source: AppstoreSource, type: AppType, slug: String): Boolean {
val isFirstSource = INITIAL_APPSTORE_SOURCES.first().url == source.url
return when (slug) {
"all-generated" -> false
"all" -> true
else -> isFirstSource
else -> !source.isRebbleFeed()
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,13 +40,17 @@ class AppstoreSourceInitializer(
) {
suspend fun initAppstoreSourcesDB() {
val current = appstoreSourceDao.getAllSources().first()
// Only builtins take part in init/migration: user-added sources have no Algolia
// credentials, so checking the whole table would flag needsInit and wipe them.
val builtinUrls = INITIAL_APPSTORE_SOURCES.map { it.url }.toSet()
val builtins = current.filter { it.url in builtinUrls }
//TODO: remove the migration stuff after a while
val needsInit = current.isEmpty() ||
current.any { it.algoliaAppId == null } || // migrate old entries
current.firstOrNull { it.url == "https://appstore-api.repebble.com/api" }?.title != "Pebble App Store" // migrate title change
val needsInit = builtins.isEmpty() ||
builtins.any { it.algoliaAppId == null } || // migrate old entries
builtins.firstOrNull { it.url == PEBBLE_FEED_URL }?.title != "Pebble App Store" // migrate title change
if (needsInit) {
logger.d { "Initializing appstore sources database" }
current.forEach { source ->
builtins.forEach { source ->
appstoreSourceDao.deleteSourceById(source.id)
}
INITIAL_APPSTORE_SOURCES.forEach { source ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -326,10 +326,12 @@ class RealPebbleWebServices(
}
}

private val appstoreServices = mutableMapOf<String, AppstoreService>()
// Keyed by id: a deleted-and-re-added source keeps its url but gets a new row id, and a
// service holding the old id writes collections/hearts that fail the foreign key.
private val appstoreServices = mutableMapOf<Int, AppstoreService>()

private fun appstoreServiceForSource(source: AppstoreSource): AppstoreService {
return appstoreServices.getOrPut(source.url) {
return appstoreServices.getOrPut(source.id) {
get {
parametersOf(source)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,22 @@ package coredevices.pebble.ui

import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Link
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
Expand All @@ -38,6 +44,8 @@ import coredevices.database.AppstoreSource
import coredevices.database.AppstoreSourceDao
import coredevices.pebble.account.PebbleAccount
import coredevices.pebble.services.PebbleWebServices
import coredevices.pebble.services.isPebbleFeed
import coredevices.pebble.services.isRebbleFeed
import coredevices.ui.M3Dialog
import io.ktor.http.URLProtocol
import io.ktor.http.parseUrl
Expand Down Expand Up @@ -95,15 +103,21 @@ class AppstoreSettingsScreenViewModel(
viewModelScope.launch {
val source = AppstoreSource(
title = title,
url = url
url = url.normalizeSourceUrl(),
)
sourceDao.insertSource(source)
updateCollections()
}
}
}

@Composable
fun AppstoreSettingsScreen(nav: NavBarNav, topBarParams: TopBarParams) {
fun AppstoreSettingsScreen(
nav: NavBarNav,
topBarParams: TopBarParams,
addSourceName: String? = null,
addSourceUrl: String? = null,
) {
val uriHandler = LocalUriHandler.current
val viewModel = koinViewModel<AppstoreSettingsScreenViewModel> { parametersOf(uriHandler) }
val sources by viewModel.sources.collectAsState()
Expand Down Expand Up @@ -141,10 +155,15 @@ fun AppstoreSettingsScreen(nav: NavBarNav, topBarParams: TopBarParams) {
}
} else {
sourceDao.setSourceEnabled(sourceId, isEnabled)
if (isEnabled) {
viewModel.updateCollections()
}
}
}
},
onCollectionEnabledChanged = viewModel::updateCollectionEnabled,
addSourceName = addSourceName,
addSourceUrl = addSourceUrl,
)
}

Expand All @@ -156,21 +175,26 @@ fun AppstoreSettingsScreen(
onSourceAdded: (title: String, url: String) -> Unit,
onSourceEnableChange: (Int, Boolean) -> Unit,
onCollectionEnabledChanged: (AppstoreCollection, Boolean) -> Unit,
addSourceName: String? = null,
addSourceUrl: String? = null,
) {
var createSourceOpen by remember { mutableStateOf(false) }
var createSourceOpen by remember { mutableStateOf(addSourceName != null && addSourceUrl != null) }
Scaffold(
/*floatingActionButton = {
floatingActionButton = {
FloatingActionButton(
onClick = {
createSourceOpen = true
}
) {
Icon(Icons.Filled.Add, contentDescription = "Add Source")
}
}*/
}
) { insets ->
if (createSourceOpen) {
CreateAppstoreSourceDialog(
existingUrls = sources.mapTo(mutableSetOf()) { it.url },
initialTitle = addSourceName.orEmpty(),
initialUrl = addSourceUrl.orEmpty(),
onDismissRequest = {
createSourceOpen = false
},
Expand All @@ -180,7 +204,10 @@ fun AppstoreSettingsScreen(
}
)
}
LazyColumn(Modifier.padding(insets)) {
LazyColumn(
modifier = Modifier.padding(insets),
contentPadding = PaddingValues(bottom = 88.dp),
) {
items(sources.size, { sources[it].id }) { i ->
val source = sources[i]
val collections = collections?.get(source)
Expand Down Expand Up @@ -213,19 +240,23 @@ fun AppstoreSourceItem(
Text(text = source.url)
},
trailingContent = {
Checkbox(
checked = source.enabled,
onCheckedChange = {
onEnableChange(source.id, it)
}
)
/*IconButton(
onClick = {
onRemove(source.id)
Row(verticalAlignment = Alignment.CenterVertically) {
if (!source.isPebbleFeed() && !source.isRebbleFeed()) {
IconButton(
onClick = {
onRemove(source.id)
}
) {
Icon(Icons.Default.Delete, contentDescription = "Delete Source")
}
}
) {
Icon(Icons.Default.Delete, contentDescription = "Delete Source")
}*/
Checkbox(
checked = source.enabled,
onCheckedChange = {
onEnableChange(source.id, it)
}
)
}
},
modifier = Modifier.clickable {
onEnableChange(source.id, !source.enabled)
Expand Down Expand Up @@ -278,13 +309,26 @@ fun AppstoreSourceItem(
}
}

private fun String.normalizeSourceUrl() = trim().trimEnd('/')

@Composable
fun CreateAppstoreSourceDialog(
existingUrls: Set<String>,
initialTitle: String = "",
initialUrl: String = "",
onDismissRequest: () -> Unit,
onSourceCreated: (title: String, url: String) -> Unit,
) {
var title by remember { mutableStateOf("") }
var url by remember { mutableStateOf("") }
var title by remember { mutableStateOf(initialTitle) }
var url by remember { mutableStateOf(initialUrl) }
val normalizedUrl = url.normalizeSourceUrl()
val parsedUrl = parseUrl(normalizedUrl)
// Feed urls are concatenated into request paths, so anything past the path is a broken source.
val urlValid = parsedUrl != null &&
parsedUrl.protocolOrNull in setOf(URLProtocol.HTTP, URLProtocol.HTTPS) &&
parsedUrl.encodedQuery.isEmpty() &&
parsedUrl.fragment.isEmpty() &&
normalizedUrl !in existingUrls
M3Dialog(
onDismissRequest = onDismissRequest,
icon = {
Expand All @@ -301,11 +345,9 @@ fun CreateAppstoreSourceDialog(
}
TextButton(
onClick = {
onSourceCreated(title, url)
onSourceCreated(title, normalizedUrl)
},
enabled = title.isNotBlank() &&
url.isNotBlank() &&
parseUrl(url)?.protocolOrNull in setOf(URLProtocol.HTTP, URLProtocol.HTTPS)
enabled = title.isNotBlank() && urlValid
) {
Text("Add")
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -544,7 +544,7 @@ fun LockerScreen(
)
IconButton(
onClick = {
navBarNav.navigateTo(PebbleNavBarRoutes.AppstoreSettingsRoute)
navBarNav.navigateTo(PebbleNavBarRoutes.AppstoreSettingsRoute())
},
) {
Icon(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,10 @@ object PebbleNavBarRoutes {
data object HealthRoute : NavBarRoute

@Serializable
data object AppstoreSettingsRoute : NavBarRoute
data class AppstoreSettingsRoute(
val addSourceName: String? = null,
val addSourceUrl: String? = null,
) : NavBarRoute

@Serializable
data class NotificationAppRoute(val packageName: String) : NavBarRoute
Expand Down Expand Up @@ -254,7 +257,8 @@ fun NavGraphBuilder.addNavBarRoutes(
CannedRepliesScreen(nav, topBarParams)
}
composable<PebbleNavBarRoutes.AppstoreSettingsRoute> {
AppstoreSettingsScreen(nav, topBarParams)
val route: PebbleNavBarRoutes.AppstoreSettingsRoute = it.toRoute()
AppstoreSettingsScreen(nav, topBarParams, route.addSourceName, route.addSourceUrl)
}
composable<PebbleNavBarRoutes.OfflineModelsRoute> {
ModelManagementScreen(nav, topBarParams)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,7 @@ fun WatchHomeScreen(
logger.v { "navigateToPebbleDeepLink: $it" }
val tab = when (it.route) {
is PebbleNavBarRoutes.LockerAppRoute -> WatchHomeNavTab.WatchFaces
is PebbleNavBarRoutes.AppstoreSettingsRoute -> WatchHomeNavTab.WatchFaces
is PebbleNavBarRoutes.IndexRoute -> WatchHomeNavTab.Index
is PebbleNavBarRoutes.WatchesRoute -> WatchHomeNavTab.Watches
else -> null
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -613,7 +613,7 @@ fun rememberSettingsItemsState(navBarNav: NavBarNav?, snackbarDisplay: SnackbarD
title = "Configure Appstore Sources",
topLevelType = TopLevelType.Phone,
section = Section.Apps,
action = { nav.navigateTo(PebbleNavBarRoutes.AppstoreSettingsRoute) },
action = { nav.navigateTo(PebbleNavBarRoutes.AppstoreSettingsRoute()) },
) },
basicSettingsDropdownItem(
title = "App Theme",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
package coredevices.pebble

import com.eygraber.uri.Uri
import coredevices.pebble.RealPebbleDeepLinkHandler.Companion.parseAddStoreFeedFrom
import coredevices.pebble.RealPebbleDeepLinkHandler.Companion.parseTokenFrom
import coredevices.pebble.ui.PebbleNavBarRoutes
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull

class PebbleDeepLinkHandlerTest {
@Test fun handlesIosBootConfig() {
Expand All @@ -17,4 +20,24 @@ class PebbleDeepLinkHandlerTest {
val token = parseTokenFrom(uri.path)
assertEquals("abcdefGHiJKLM01234567890OpQrST", token)
}
}

@Test fun parsesAddStoreFeed() {
val uri = Uri.parse("pebble://add-store-feed/TTMM/https%3A%2F%2Fapps.ttmm.is%2Fpebble%2Fapi")
assertEquals(
PebbleNavBarRoutes.AppstoreSettingsRoute(
addSourceName = "TTMM",
addSourceUrl = "https://apps.ttmm.is/pebble/api",
),
parseAddStoreFeedFrom(uri),
)
}

@Test fun rejectsUnencodedAddStoreFeedUrl() {
val uri = Uri.parse("pebble://add-store-feed/TTMM/https://apps.ttmm.is/pebble/api")
assertNull(parseAddStoreFeedFrom(uri))
}

@Test fun rejectsAddStoreFeedWithoutUrl() {
assertNull(parseAddStoreFeedFrom(Uri.parse("pebble://add-store-feed/TTMM")))
}
}
Loading