Skip to content

Commit 6d0759c

Browse files
committed
external app installs via kmp
1 parent 3df39fa commit 6d0759c

File tree

14 files changed

+350
-24
lines changed

14 files changed

+350
-24
lines changed

android/app/src/main/AndroidManifest.xml

Lines changed: 2 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -102,9 +102,8 @@
102102
<category android:name="android.intent.category.BROWSABLE" />
103103

104104
<data android:scheme="content" />
105-
<data android:mimeType="application/octet-stream" />
106-
<data android:mimeType="application/zip" />
107-
<data android:mimeType="binary/octet-stream" />
105+
<data android:mimeType="*/*" />
106+
<data android:pathPattern=".*\\.pbw" />
108107
</intent-filter>
109108
</activity>
110109

@@ -162,18 +161,6 @@
162161
<data android:scheme="rebble" />
163162
</intent-filter>
164163

165-
<!-- PBW Install -->
166-
<intent-filter>
167-
<action android:name="android.intent.action.VIEW" />
168-
169-
<category android:name="android.intent.category.DEFAULT" />
170-
<category android:name="android.intent.category.BROWSABLE" />
171-
172-
<data android:scheme="content" />
173-
<data android:mimeType="*/*" />
174-
<data android:pathPattern=".*\\.pbw" />
175-
</intent-filter>
176-
177164
<!-- PBZ Install -->
178165
<intent-filter>
179166
<action android:name="android.intent.action.VIEW" />

android/app/src/main/kotlin/io/rebble/cobble/MainActivity.kt

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,33 @@
11
package io.rebble.cobble
22

33
import android.content.Intent
4+
import android.net.Uri
45
import android.os.Bundle
56
import androidx.activity.compose.setContent
67
import androidx.activity.enableEdgeToEdge
78
import androidx.appcompat.app.AppCompatActivity
9+
import androidx.core.net.toUri
810
import androidx.core.view.WindowCompat
911
import androidx.lifecycle.lifecycleScope
1012
import androidx.navigation.NavHostController
1113
import androidx.navigation.compose.rememberNavController
14+
import androidx.navigation.navOptions
15+
import io.rebble.cobble.shared.AndroidPlatformContext
16+
import io.rebble.cobble.shared.Logging
17+
import io.rebble.cobble.shared.PlatformContext
18+
import io.rebble.cobble.shared.ui.nav.Routes
1219
import io.rebble.cobble.shared.ui.view.MainView
20+
import io.rebble.cobble.shared.util.File
1321
import kotlinx.coroutines.CoroutineScope
1422
import kotlinx.coroutines.plus
23+
import kotlinx.datetime.Clock
24+
import kotlinx.serialization.encodeToString
25+
import kotlinx.serialization.json.Json
1526

1627
class MainActivity : AppCompatActivity() {
1728
lateinit var coroutineScope: CoroutineScope
1829
var navHostController: NavHostController? = null
30+
1931
override fun onCreate(savedInstanceState: Bundle?) {
2032
super.onCreate(savedInstanceState)
2133

@@ -32,13 +44,45 @@ class MainActivity : AppCompatActivity() {
3244
setContent {
3345
navHostController = rememberNavController()
3446
MainView(navHostController!!)
47+
handleIntent(intent)
3548
}
3649
}
3750

3851
override fun onNewIntent(intent: Intent) {
3952
super.onNewIntent(intent)
40-
if (intent.hasExtra("navigationPath")) {
41-
navHostController?.navigate(intent.getStringExtra("navigationPath")!!)
53+
handleIntent(intent)
54+
}
55+
56+
private fun handleIntent(intent: Intent) {
57+
val uri = intent.data
58+
if (intent.scheme == "content" && uri != null) {
59+
if (uri.path?.endsWith("pbw") == true) {
60+
Logging.d("Received pbw install intent")
61+
val fileSize = contentResolver.openAssetFileDescriptor(uri, "r").use {
62+
it?.length
63+
}
64+
if (fileSize == null || fileSize > 10_000_000) {
65+
Logging.e("Invalid PBW file size, ignoring")
66+
return
67+
}
68+
val cachedUri = cacheIncomingPbw(uri)
69+
navHostController?.navigate("${Routes.DIALOG_APP_INSTALL}?uri=$cachedUri")
70+
}
71+
} else {
72+
if (intent.hasExtra("navigationPath")) {
73+
navHostController?.navigate(intent.getStringExtra("navigationPath")!!)
74+
}
75+
}
76+
}
77+
78+
private fun cacheIncomingPbw(uri: Uri): Uri {
79+
val cached = applicationContext.cacheDir.resolve("local-${Clock.System.now().toEpochMilliseconds()}.pbw")
80+
cached.deleteOnExit()
81+
applicationContext.contentResolver.openInputStream(uri).use { input ->
82+
cached.outputStream().use { output ->
83+
input!!.copyTo(output)
84+
}
4285
}
86+
return cached.toUri()
4387
}
4488
}
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
package io.rebble.cobble.shared
22

3+
import android.content.Context
34
import io.rebble.libpebblecommon.packets.PhoneAppVersion
45

56
class AndroidPlatformContext(
6-
val applicationContext: android.content.Context
7+
val applicationContext: Context
78
) : PlatformContext {
89
override val osType: PhoneAppVersion.OSType = PhoneAppVersion.OSType.Android
910
}

android/shared/src/androidMain/kotlin/io/rebble/cobble/shared/util/File.android.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,13 @@ package io.rebble.cobble.shared.util
22

33
import android.net.Uri
44
import androidx.core.net.toFile
5+
import io.ktor.utils.io.ByteReadChannel
6+
import io.ktor.utils.io.jvm.javaio.toByteReadChannel
57

68
actual class File actual constructor(uri: String) {
79
val file = Uri.parse(uri).toFile()
810
actual fun exists(): Boolean = file.exists()
11+
actual fun readChannel(): ByteReadChannel = file.inputStream().toByteReadChannel()
912
}
1013

1114
fun java.io.File.toKMPFile(): File {

android/shared/src/commonMain/kotlin/io/rebble/cobble/shared/database/AppDatabase.kt

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import org.koin.mp.KoinPlatformTools
1919
SyncedLockerEntry::class,
2020
SyncedLockerEntryPlatform::class
2121
],
22-
version = 11,
22+
version = 12,
2323
autoMigrations = [
2424
AutoMigration(1, 2),
2525
AutoMigration(2, 3),
@@ -30,7 +30,8 @@ import org.koin.mp.KoinPlatformTools
3030
AutoMigration(7, 8),
3131
AutoMigration(8, 9),
3232
AutoMigration(9, 10),
33-
AutoMigration(10, 11)
33+
AutoMigration(10, 11),
34+
AutoMigration(11, 12)
3435
]
3536
)
3637
@TypeConverters(Converters::class)

android/shared/src/commonMain/kotlin/io/rebble/cobble/shared/database/entity/SyncedLockerEntry.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ data class SyncedLockerEntry(
3333
@ColumnInfo(defaultValue = "-1")
3434
val order: Int,
3535
val lastOpened: Instant?,
36+
@ColumnInfo(defaultValue = "0")
37+
val local: Boolean = false
3638
)
3739

3840
data class SyncedLockerEntryWithPlatforms(
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
package io.rebble.cobble.shared.domain.pbw
2+
3+
import io.rebble.cobble.shared.Logging
4+
import io.rebble.cobble.shared.PlatformContext
5+
import io.rebble.cobble.shared.database.AppDatabase
6+
import io.rebble.cobble.shared.database.NextSyncAction
7+
import io.rebble.cobble.shared.database.dao.LockerDao
8+
import io.rebble.cobble.shared.database.entity.SyncedLockerEntry
9+
import io.rebble.cobble.shared.database.entity.SyncedLockerEntryPlatform
10+
import io.rebble.cobble.shared.database.entity.SyncedLockerEntryPlatformImages
11+
import io.rebble.cobble.shared.database.getDatabase
12+
import io.rebble.cobble.shared.handlers.savePbwFile
13+
import io.rebble.cobble.shared.jobs.LockerSyncJob
14+
import io.rebble.cobble.shared.util.*
15+
import io.rebble.libpebblecommon.disk.PbwBinHeader
16+
import io.rebble.libpebblecommon.metadata.WatchType
17+
import io.rebble.libpebblecommon.util.DataBuffer
18+
import okio.buffer
19+
import org.koin.core.component.KoinComponent
20+
import org.koin.core.component.inject
21+
22+
class PbwApp(uri: String): KoinComponent {
23+
private val platformContext: PlatformContext by inject()
24+
private val file = File(uri)
25+
private val lockerDao: LockerDao by inject()
26+
init {
27+
require(file.exists())
28+
}
29+
30+
val info by lazy { requirePbwAppInfo(file) }
31+
32+
fun getManifest(watchType: WatchType) = requirePbwManifest(file, watchType)
33+
34+
suspend fun installToLockerCache(): Boolean {
35+
val exists = lockerDao.getEntryByUuid(info.uuid)
36+
lockerDao.insertOrReplace(
37+
SyncedLockerEntry(
38+
id = exists?.entry?.id ?: "",
39+
uuid = info.uuid,
40+
version = info.versionLabel,
41+
title = info.shortName,
42+
type = if (info.watchapp.watchface) "watchface" else "watchapp",
43+
hearts = 0,
44+
developerName = info.companyName,
45+
developerId = null,
46+
configurable = info.capabilities.any { it == "configurable" },
47+
timelineEnabled = false, //TODO
48+
removeLink = "",
49+
shareLink = "",
50+
pbwLink = "",
51+
pbwReleaseId = info.versionCode.toString(),
52+
pbwIconResourceId = 0, //TODO,
53+
NextSyncAction.Upload,
54+
order = -1,
55+
lastOpened = null,
56+
local = true
57+
)
58+
)
59+
60+
val inserted = lockerDao.getEntryByUuid(info.uuid) ?: return false
61+
62+
if (exists != null) {
63+
lockerDao.clearPlatformsFor(exists.entry.id)
64+
}
65+
66+
val platforms = WatchType.entries.mapNotNull {
67+
val manifest = getPbwManifest(file, it)
68+
if (manifest != null) {
69+
val header = PbwBinHeader().apply {
70+
fromBytes(DataBuffer(requirePbwBinaryBlob(file, it, "pebble-app.bin")
71+
.buffer()
72+
.readByteArray((PbwBinHeader.SIZE+1).toLong()).asUByteArray()
73+
))
74+
}
75+
76+
SyncedLockerEntryPlatform(
77+
platformEntryId = 0,
78+
lockerEntryId = inserted.entry.id,
79+
sdkVersion = "${header.sdkVersionMajor.get()}.${header.sdkVersionMinor.get()}",
80+
processInfoFlags = header.flags.get().toInt(),
81+
name = it.codename,
82+
description = "",
83+
images = SyncedLockerEntryPlatformImages(
84+
null,
85+
null,
86+
null
87+
)
88+
)
89+
} else {
90+
null
91+
}
92+
}
93+
if (platforms.isEmpty()) {
94+
Logging.e("No platforms in PBW")
95+
return false
96+
}
97+
98+
lockerDao.insertOrReplaceAllPlatforms(platforms)
99+
100+
savePbwFile(platformContext, info.uuid, file.readChannel())
101+
if (!LockerSyncJob().syncToDevice()) {
102+
Logging.e("Failed to sync locker to device")
103+
return false
104+
}
105+
return true
106+
}
107+
}

android/shared/src/commonMain/kotlin/io/rebble/cobble/shared/jobs/LockerSyncJob.kt

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,13 +40,13 @@ class LockerSyncJob: KoinComponent {
4040
}
4141

4242
val changedEntries = locker.filter { new ->
43-
val newPlat = new.hardwarePlatforms.map { it.toEntity(new.id) }
43+
val newPlat = new.hardwarePlatforms.map { it.toEntity(new.id) }
4444
storedLocker.any { old ->
45-
old.entry.nextSyncAction != NextSyncAction.Ignore && old.entry.id == new.id && (old.entry != new.toEntity() || old.platforms.any { oldPlat -> newPlat.none { newPlat -> oldPlat.dataEqualTo(newPlat) } })
45+
old.entry.nextSyncAction != NextSyncAction.Ignore && old.entry.id == new.id && old.entry.version != new.version
4646
}
4747
}
4848
val newEntries = locker.filter { new -> storedLocker.none { old -> old.entry.id == new.id } }
49-
val removedEntries = storedLocker.filter { old -> locker.none { nw -> nw.id == old.entry.id } }
49+
val removedEntries = storedLocker.filter { old -> locker.none { nw -> nw.id == old.entry.id } && !old.entry.local }
5050

5151
lockerDao.insertOrReplaceAll(newEntries.map { it.toEntity() })
5252
changedEntries.forEach {
@@ -83,7 +83,7 @@ class LockerSyncJob: KoinComponent {
8383
return syncToDevice()
8484
}
8585

86-
private suspend fun syncToDevice(): Boolean {
86+
suspend fun syncToDevice(): Boolean {
8787
val entries = lockerDao.getEntriesForSync().sortedBy { it.entry.title }
8888
val connectedWatch = ConnectionStateManager.connectionState.value.watchOrNull
8989
connectedWatch?.let {

android/shared/src/commonMain/kotlin/io/rebble/cobble/shared/ui/nav/Routes.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package io.rebble.cobble.shared.ui.nav
22

33
object Routes {
4+
const val DIALOG_APP_INSTALL = "dialog_app_install"
45
object Home {
56
const val LOCKER_APPS = "locker_apps"
67
const val LOCKER_WATCHFACES = "locker_watchfaces"

android/shared/src/commonMain/kotlin/io/rebble/cobble/shared/ui/view/MainView.kt

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,23 @@ import androidx.compose.foundation.text.selection.DisableSelection
55
import androidx.compose.material3.MaterialTheme
66
import androidx.compose.runtime.Composable
77
import androidx.compose.runtime.CompositionLocalProvider
8+
import androidx.navigation.NamedNavArgument
89
import androidx.navigation.NavHostController
10+
import androidx.navigation.NavType
911
import androidx.navigation.compose.NavHost
1012
import androidx.navigation.compose.composable
13+
import androidx.navigation.compose.dialog
1114
import androidx.navigation.compose.rememberNavController
15+
import androidx.navigation.navArgument
1216
import io.rebble.cobble.shared.ui.LocalTheme
1317
import io.rebble.cobble.shared.ui.Theme
1418
import io.rebble.cobble.shared.ui.nav.Routes
19+
import io.rebble.cobble.shared.ui.view.dialogs.AppInstallDialog
1520
import io.rebble.cobble.shared.ui.view.home.HomePage
1621
import io.rebble.cobble.shared.ui.view.home.HomeScaffold
1722
import io.rebble.cobble.shared.ui.view.home.locker.LockerTabs
23+
import kotlinx.serialization.json.Json
24+
import kotlinx.serialization.json.Json.Default.decodeFromString
1825
import org.koin.compose.KoinContext
1926

2027
@Composable
@@ -39,6 +46,15 @@ fun MainView(navController: NavHostController = rememberNavController()) {
3946
composable(Routes.Home.TEST_PAGE) {
4047
HomeScaffold(HomePage.TestPage, onNavChange = navController::navigate)
4148
}
49+
dialog("${Routes.DIALOG_APP_INSTALL}?uri={uri}", arguments = listOf(navArgument("uri") {
50+
nullable = false
51+
type = NavType.StringType
52+
})) {
53+
val uri = it.arguments?.getString("uri") ?: return@dialog
54+
AppInstallDialog(uri) {
55+
navController.popBackStack()
56+
}
57+
}
4258
}
4359
}
4460
}

0 commit comments

Comments
 (0)