From 6d0759cbae7a016f43e3dd26325e6af7e377dc95 Mon Sep 17 00:00:00 2001 From: crc-32 Date: Tue, 15 Oct 2024 17:34:34 +0100 Subject: [PATCH] external app installs via kmp --- android/app/src/main/AndroidManifest.xml | 17 +-- .../kotlin/io/rebble/cobble/MainActivity.kt | 48 +++++++- .../cobble/shared/AndroidPlatformContext.kt | 3 +- .../rebble/cobble/shared/util/File.android.kt | 3 + .../cobble/shared/database/AppDatabase.kt | 5 +- .../database/entity/SyncedLockerEntry.kt | 2 + .../rebble/cobble/shared/domain/pbw/PbwApp.kt | 107 ++++++++++++++++++ .../cobble/shared/jobs/LockerSyncJob.kt | 8 +- .../io/rebble/cobble/shared/ui/nav/Routes.kt | 1 + .../rebble/cobble/shared/ui/view/MainView.kt | 16 +++ .../ui/view/dialogs/AppInstallDialog.kt | 98 ++++++++++++++++ .../ui/viewmodel/AppInstallDialogViewModel.kt | 55 +++++++++ .../io/rebble/cobble/shared/util/File.kt | 3 + .../cobble/shared/domain/pbw/PbwApp.ios.kt | 8 ++ 14 files changed, 350 insertions(+), 24 deletions(-) create mode 100644 android/shared/src/commonMain/kotlin/io/rebble/cobble/shared/domain/pbw/PbwApp.kt create mode 100644 android/shared/src/commonMain/kotlin/io/rebble/cobble/shared/ui/view/dialogs/AppInstallDialog.kt create mode 100644 android/shared/src/commonMain/kotlin/io/rebble/cobble/shared/ui/viewmodel/AppInstallDialogViewModel.kt create mode 100644 android/shared/src/iosMain/kotlin/io/rebble/cobble/shared/domain/pbw/PbwApp.ios.kt diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index a633ae76..ee846786 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -102,9 +102,8 @@ - - - + + @@ -162,18 +161,6 @@ - - - - - - - - - - - - diff --git a/android/app/src/main/kotlin/io/rebble/cobble/MainActivity.kt b/android/app/src/main/kotlin/io/rebble/cobble/MainActivity.kt index 7d8dfdb0..e323f9fc 100644 --- a/android/app/src/main/kotlin/io/rebble/cobble/MainActivity.kt +++ b/android/app/src/main/kotlin/io/rebble/cobble/MainActivity.kt @@ -1,21 +1,33 @@ package io.rebble.cobble import android.content.Intent +import android.net.Uri import android.os.Bundle import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.appcompat.app.AppCompatActivity +import androidx.core.net.toUri import androidx.core.view.WindowCompat import androidx.lifecycle.lifecycleScope import androidx.navigation.NavHostController import androidx.navigation.compose.rememberNavController +import androidx.navigation.navOptions +import io.rebble.cobble.shared.AndroidPlatformContext +import io.rebble.cobble.shared.Logging +import io.rebble.cobble.shared.PlatformContext +import io.rebble.cobble.shared.ui.nav.Routes import io.rebble.cobble.shared.ui.view.MainView +import io.rebble.cobble.shared.util.File import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.plus +import kotlinx.datetime.Clock +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json class MainActivity : AppCompatActivity() { lateinit var coroutineScope: CoroutineScope var navHostController: NavHostController? = null + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -32,13 +44,45 @@ class MainActivity : AppCompatActivity() { setContent { navHostController = rememberNavController() MainView(navHostController!!) + handleIntent(intent) } } override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) - if (intent.hasExtra("navigationPath")) { - navHostController?.navigate(intent.getStringExtra("navigationPath")!!) + handleIntent(intent) + } + + private fun handleIntent(intent: Intent) { + val uri = intent.data + if (intent.scheme == "content" && uri != null) { + if (uri.path?.endsWith("pbw") == true) { + Logging.d("Received pbw install intent") + val fileSize = contentResolver.openAssetFileDescriptor(uri, "r").use { + it?.length + } + if (fileSize == null || fileSize > 10_000_000) { + Logging.e("Invalid PBW file size, ignoring") + return + } + val cachedUri = cacheIncomingPbw(uri) + navHostController?.navigate("${Routes.DIALOG_APP_INSTALL}?uri=$cachedUri") + } + } else { + if (intent.hasExtra("navigationPath")) { + navHostController?.navigate(intent.getStringExtra("navigationPath")!!) + } + } + } + + private fun cacheIncomingPbw(uri: Uri): Uri { + val cached = applicationContext.cacheDir.resolve("local-${Clock.System.now().toEpochMilliseconds()}.pbw") + cached.deleteOnExit() + applicationContext.contentResolver.openInputStream(uri).use { input -> + cached.outputStream().use { output -> + input!!.copyTo(output) + } } + return cached.toUri() } } \ No newline at end of file diff --git a/android/shared/src/androidMain/kotlin/io/rebble/cobble/shared/AndroidPlatformContext.kt b/android/shared/src/androidMain/kotlin/io/rebble/cobble/shared/AndroidPlatformContext.kt index 41bc6a3f..6e06a6bc 100644 --- a/android/shared/src/androidMain/kotlin/io/rebble/cobble/shared/AndroidPlatformContext.kt +++ b/android/shared/src/androidMain/kotlin/io/rebble/cobble/shared/AndroidPlatformContext.kt @@ -1,9 +1,10 @@ package io.rebble.cobble.shared +import android.content.Context import io.rebble.libpebblecommon.packets.PhoneAppVersion class AndroidPlatformContext( - val applicationContext: android.content.Context + val applicationContext: Context ) : PlatformContext { override val osType: PhoneAppVersion.OSType = PhoneAppVersion.OSType.Android } \ No newline at end of file diff --git a/android/shared/src/androidMain/kotlin/io/rebble/cobble/shared/util/File.android.kt b/android/shared/src/androidMain/kotlin/io/rebble/cobble/shared/util/File.android.kt index 51e50c4c..64e012fd 100644 --- a/android/shared/src/androidMain/kotlin/io/rebble/cobble/shared/util/File.android.kt +++ b/android/shared/src/androidMain/kotlin/io/rebble/cobble/shared/util/File.android.kt @@ -2,10 +2,13 @@ package io.rebble.cobble.shared.util import android.net.Uri import androidx.core.net.toFile +import io.ktor.utils.io.ByteReadChannel +import io.ktor.utils.io.jvm.javaio.toByteReadChannel actual class File actual constructor(uri: String) { val file = Uri.parse(uri).toFile() actual fun exists(): Boolean = file.exists() + actual fun readChannel(): ByteReadChannel = file.inputStream().toByteReadChannel() } fun java.io.File.toKMPFile(): File { diff --git a/android/shared/src/commonMain/kotlin/io/rebble/cobble/shared/database/AppDatabase.kt b/android/shared/src/commonMain/kotlin/io/rebble/cobble/shared/database/AppDatabase.kt index a1be50ce..e344bf81 100644 --- a/android/shared/src/commonMain/kotlin/io/rebble/cobble/shared/database/AppDatabase.kt +++ b/android/shared/src/commonMain/kotlin/io/rebble/cobble/shared/database/AppDatabase.kt @@ -19,7 +19,7 @@ import org.koin.mp.KoinPlatformTools SyncedLockerEntry::class, SyncedLockerEntryPlatform::class ], - version = 11, + version = 12, autoMigrations = [ AutoMigration(1, 2), AutoMigration(2, 3), @@ -30,7 +30,8 @@ import org.koin.mp.KoinPlatformTools AutoMigration(7, 8), AutoMigration(8, 9), AutoMigration(9, 10), - AutoMigration(10, 11) + AutoMigration(10, 11), + AutoMigration(11, 12) ] ) @TypeConverters(Converters::class) diff --git a/android/shared/src/commonMain/kotlin/io/rebble/cobble/shared/database/entity/SyncedLockerEntry.kt b/android/shared/src/commonMain/kotlin/io/rebble/cobble/shared/database/entity/SyncedLockerEntry.kt index 1472a2c5..6e0facd9 100644 --- a/android/shared/src/commonMain/kotlin/io/rebble/cobble/shared/database/entity/SyncedLockerEntry.kt +++ b/android/shared/src/commonMain/kotlin/io/rebble/cobble/shared/database/entity/SyncedLockerEntry.kt @@ -33,6 +33,8 @@ data class SyncedLockerEntry( @ColumnInfo(defaultValue = "-1") val order: Int, val lastOpened: Instant?, + @ColumnInfo(defaultValue = "0") + val local: Boolean = false ) data class SyncedLockerEntryWithPlatforms( diff --git a/android/shared/src/commonMain/kotlin/io/rebble/cobble/shared/domain/pbw/PbwApp.kt b/android/shared/src/commonMain/kotlin/io/rebble/cobble/shared/domain/pbw/PbwApp.kt new file mode 100644 index 00000000..7805f9f3 --- /dev/null +++ b/android/shared/src/commonMain/kotlin/io/rebble/cobble/shared/domain/pbw/PbwApp.kt @@ -0,0 +1,107 @@ +package io.rebble.cobble.shared.domain.pbw + +import io.rebble.cobble.shared.Logging +import io.rebble.cobble.shared.PlatformContext +import io.rebble.cobble.shared.database.AppDatabase +import io.rebble.cobble.shared.database.NextSyncAction +import io.rebble.cobble.shared.database.dao.LockerDao +import io.rebble.cobble.shared.database.entity.SyncedLockerEntry +import io.rebble.cobble.shared.database.entity.SyncedLockerEntryPlatform +import io.rebble.cobble.shared.database.entity.SyncedLockerEntryPlatformImages +import io.rebble.cobble.shared.database.getDatabase +import io.rebble.cobble.shared.handlers.savePbwFile +import io.rebble.cobble.shared.jobs.LockerSyncJob +import io.rebble.cobble.shared.util.* +import io.rebble.libpebblecommon.disk.PbwBinHeader +import io.rebble.libpebblecommon.metadata.WatchType +import io.rebble.libpebblecommon.util.DataBuffer +import okio.buffer +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject + +class PbwApp(uri: String): KoinComponent { + private val platformContext: PlatformContext by inject() + private val file = File(uri) + private val lockerDao: LockerDao by inject() + init { + require(file.exists()) + } + + val info by lazy { requirePbwAppInfo(file) } + + fun getManifest(watchType: WatchType) = requirePbwManifest(file, watchType) + + suspend fun installToLockerCache(): Boolean { + val exists = lockerDao.getEntryByUuid(info.uuid) + lockerDao.insertOrReplace( + SyncedLockerEntry( + id = exists?.entry?.id ?: "", + uuid = info.uuid, + version = info.versionLabel, + title = info.shortName, + type = if (info.watchapp.watchface) "watchface" else "watchapp", + hearts = 0, + developerName = info.companyName, + developerId = null, + configurable = info.capabilities.any { it == "configurable" }, + timelineEnabled = false, //TODO + removeLink = "", + shareLink = "", + pbwLink = "", + pbwReleaseId = info.versionCode.toString(), + pbwIconResourceId = 0, //TODO, + NextSyncAction.Upload, + order = -1, + lastOpened = null, + local = true + ) + ) + + val inserted = lockerDao.getEntryByUuid(info.uuid) ?: return false + + if (exists != null) { + lockerDao.clearPlatformsFor(exists.entry.id) + } + + val platforms = WatchType.entries.mapNotNull { + val manifest = getPbwManifest(file, it) + if (manifest != null) { + val header = PbwBinHeader().apply { + fromBytes(DataBuffer(requirePbwBinaryBlob(file, it, "pebble-app.bin") + .buffer() + .readByteArray((PbwBinHeader.SIZE+1).toLong()).asUByteArray() + )) + } + + SyncedLockerEntryPlatform( + platformEntryId = 0, + lockerEntryId = inserted.entry.id, + sdkVersion = "${header.sdkVersionMajor.get()}.${header.sdkVersionMinor.get()}", + processInfoFlags = header.flags.get().toInt(), + name = it.codename, + description = "", + images = SyncedLockerEntryPlatformImages( + null, + null, + null + ) + ) + } else { + null + } + } + if (platforms.isEmpty()) { + Logging.e("No platforms in PBW") + return false + } + + lockerDao.insertOrReplaceAllPlatforms(platforms) + + savePbwFile(platformContext, info.uuid, file.readChannel()) + if (!LockerSyncJob().syncToDevice()) { + Logging.e("Failed to sync locker to device") + return false + } + return true + } +} \ No newline at end of file diff --git a/android/shared/src/commonMain/kotlin/io/rebble/cobble/shared/jobs/LockerSyncJob.kt b/android/shared/src/commonMain/kotlin/io/rebble/cobble/shared/jobs/LockerSyncJob.kt index 3b2cdd13..55a873e9 100644 --- a/android/shared/src/commonMain/kotlin/io/rebble/cobble/shared/jobs/LockerSyncJob.kt +++ b/android/shared/src/commonMain/kotlin/io/rebble/cobble/shared/jobs/LockerSyncJob.kt @@ -40,13 +40,13 @@ class LockerSyncJob: KoinComponent { } val changedEntries = locker.filter { new -> - val newPlat = new.hardwarePlatforms.map { it.toEntity(new.id) } + val newPlat = new.hardwarePlatforms.map { it.toEntity(new.id) } storedLocker.any { old -> - old.entry.nextSyncAction != NextSyncAction.Ignore && old.entry.id == new.id && (old.entry != new.toEntity() || old.platforms.any { oldPlat -> newPlat.none { newPlat -> oldPlat.dataEqualTo(newPlat) } }) + old.entry.nextSyncAction != NextSyncAction.Ignore && old.entry.id == new.id && old.entry.version != new.version } } val newEntries = locker.filter { new -> storedLocker.none { old -> old.entry.id == new.id } } - val removedEntries = storedLocker.filter { old -> locker.none { nw -> nw.id == old.entry.id } } + val removedEntries = storedLocker.filter { old -> locker.none { nw -> nw.id == old.entry.id } && !old.entry.local } lockerDao.insertOrReplaceAll(newEntries.map { it.toEntity() }) changedEntries.forEach { @@ -83,7 +83,7 @@ class LockerSyncJob: KoinComponent { return syncToDevice() } - private suspend fun syncToDevice(): Boolean { + suspend fun syncToDevice(): Boolean { val entries = lockerDao.getEntriesForSync().sortedBy { it.entry.title } val connectedWatch = ConnectionStateManager.connectionState.value.watchOrNull connectedWatch?.let { diff --git a/android/shared/src/commonMain/kotlin/io/rebble/cobble/shared/ui/nav/Routes.kt b/android/shared/src/commonMain/kotlin/io/rebble/cobble/shared/ui/nav/Routes.kt index eac01fc2..bf37c617 100644 --- a/android/shared/src/commonMain/kotlin/io/rebble/cobble/shared/ui/nav/Routes.kt +++ b/android/shared/src/commonMain/kotlin/io/rebble/cobble/shared/ui/nav/Routes.kt @@ -1,6 +1,7 @@ package io.rebble.cobble.shared.ui.nav object Routes { + const val DIALOG_APP_INSTALL = "dialog_app_install" object Home { const val LOCKER_APPS = "locker_apps" const val LOCKER_WATCHFACES = "locker_watchfaces" diff --git a/android/shared/src/commonMain/kotlin/io/rebble/cobble/shared/ui/view/MainView.kt b/android/shared/src/commonMain/kotlin/io/rebble/cobble/shared/ui/view/MainView.kt index 446219b7..668eb1ee 100644 --- a/android/shared/src/commonMain/kotlin/io/rebble/cobble/shared/ui/view/MainView.kt +++ b/android/shared/src/commonMain/kotlin/io/rebble/cobble/shared/ui/view/MainView.kt @@ -5,16 +5,23 @@ import androidx.compose.foundation.text.selection.DisableSelection import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.navigation.NamedNavArgument import androidx.navigation.NavHostController +import androidx.navigation.NavType import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable +import androidx.navigation.compose.dialog import androidx.navigation.compose.rememberNavController +import androidx.navigation.navArgument import io.rebble.cobble.shared.ui.LocalTheme import io.rebble.cobble.shared.ui.Theme import io.rebble.cobble.shared.ui.nav.Routes +import io.rebble.cobble.shared.ui.view.dialogs.AppInstallDialog import io.rebble.cobble.shared.ui.view.home.HomePage import io.rebble.cobble.shared.ui.view.home.HomeScaffold import io.rebble.cobble.shared.ui.view.home.locker.LockerTabs +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.Json.Default.decodeFromString import org.koin.compose.KoinContext @Composable @@ -39,6 +46,15 @@ fun MainView(navController: NavHostController = rememberNavController()) { composable(Routes.Home.TEST_PAGE) { HomeScaffold(HomePage.TestPage, onNavChange = navController::navigate) } + dialog("${Routes.DIALOG_APP_INSTALL}?uri={uri}", arguments = listOf(navArgument("uri") { + nullable = false + type = NavType.StringType + })) { + val uri = it.arguments?.getString("uri") ?: return@dialog + AppInstallDialog(uri) { + navController.popBackStack() + } + } } } } diff --git a/android/shared/src/commonMain/kotlin/io/rebble/cobble/shared/ui/view/dialogs/AppInstallDialog.kt b/android/shared/src/commonMain/kotlin/io/rebble/cobble/shared/ui/view/dialogs/AppInstallDialog.kt new file mode 100644 index 00000000..310a6141 --- /dev/null +++ b/android/shared/src/commonMain/kotlin/io/rebble/cobble/shared/ui/view/dialogs/AppInstallDialog.kt @@ -0,0 +1,98 @@ +package io.rebble.cobble.shared.ui.view.dialogs + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.width +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import io.rebble.cobble.shared.ui.LocalTheme +import io.rebble.cobble.shared.ui.common.RebbleIcons +import io.rebble.cobble.shared.ui.viewmodel.AppInstallDialogViewModel + +@Composable +fun AppInstallDialog(uri: String, onDismissRequest: () -> Unit) { + val viewModel = viewModel { AppInstallDialogViewModel(uri) } + val appInstallState by viewModel.state.collectAsState() + AlertDialog( + onDismissRequest = onDismissRequest, + iconContentColor = LocalTheme.current.materialColors.primary, + icon = { + when (appInstallState) { + null -> RebbleIcons.warning() + is AppInstallDialogViewModel.AppInstallState.Installing -> RebbleIcons.appsCrate() + is AppInstallDialogViewModel.AppInstallState.Error -> RebbleIcons.warning() + is AppInstallDialogViewModel.AppInstallState.Success -> RebbleIcons.sendToWatchChecked() + } + }, + title = { + when (appInstallState) { + null -> Text("Install external app?") + is AppInstallDialogViewModel.AppInstallState.Installing -> Text("Installing...") + is AppInstallDialogViewModel.AppInstallState.Error -> Text("Error") + is AppInstallDialogViewModel.AppInstallState.Success -> Text("Successfully Installed") + } + }, + text = { + when (appInstallState) { + null -> Text( + buildAnnotatedString { + append("Do you want to install the app ") + withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { + append(viewModel.app.info.longName.ifBlank { viewModel.app.info.shortName }) + } + append(" by ") + withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { + append(viewModel.app.info.companyName.ifBlank { "" }) + } + append("?\nPlease note there's no verification of the app's authenticity or safety.") + } + ) + is AppInstallDialogViewModel.AppInstallState.Installing -> { + val progress = (appInstallState as AppInstallDialogViewModel.AppInstallState.Installing).progress.toFloat() + LinearProgressIndicator( + progress = { progress } + ) + } + is AppInstallDialogViewModel.AppInstallState.Success -> {} + is AppInstallDialogViewModel.AppInstallState.Error -> { + val message = (appInstallState as AppInstallDialogViewModel.AppInstallState.Error).message + Text(message) + } + } + }, + confirmButton = { if (appInstallState == null) { + TextButton( + content = { Text("Install") }, + onClick = { + viewModel.installApp() + } + ) + } else if (appInstallState is AppInstallDialogViewModel.AppInstallState.Success || appInstallState is AppInstallDialogViewModel.AppInstallState.Error) { + TextButton( + content = { Text("Close") }, + onClick = { onDismissRequest() } + ) + } + }, + dismissButton = { if (appInstallState == null) { + TextButton( + content = { Text("Cancel") }, + onClick = { onDismissRequest() } + ) + } + } + ) +} \ No newline at end of file diff --git a/android/shared/src/commonMain/kotlin/io/rebble/cobble/shared/ui/viewmodel/AppInstallDialogViewModel.kt b/android/shared/src/commonMain/kotlin/io/rebble/cobble/shared/ui/viewmodel/AppInstallDialogViewModel.kt new file mode 100644 index 00000000..8d0f602f --- /dev/null +++ b/android/shared/src/commonMain/kotlin/io/rebble/cobble/shared/ui/viewmodel/AppInstallDialogViewModel.kt @@ -0,0 +1,55 @@ +package io.rebble.cobble.shared.ui.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.benasher44.uuid.uuidFrom +import io.rebble.cobble.shared.database.dao.LockerDao +import io.rebble.cobble.shared.domain.pbw.PbwApp +import io.rebble.cobble.shared.domain.state.ConnectionStateManager +import io.rebble.cobble.shared.domain.state.watchOrNull +import io.rebble.cobble.shared.middleware.PutBytesController +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import kotlin.time.Duration.Companion.seconds + +class AppInstallDialogViewModel(val uri: String): ViewModel() { + open class AppInstallState { + class Installing(val progress: Double): AppInstallState() + object Success: AppInstallState() + class Error(val message: String): AppInstallState() + } + private val _state = MutableStateFlow(null) + val state = _state.asStateFlow() + + val app = PbwApp(uri) + + fun installApp() { + viewModelScope.launch { + _state.value = AppInstallState.Installing(0.0) + try { + app.installToLockerCache() + val device = ConnectionStateManager.connectionState.value.watchOrNull + if (device == null) { + _state.value = AppInstallState.Error("No connected device") + return@launch + } + device.appRunStateService.startApp(uuidFrom(app.info.uuid)) + viewModelScope.launch { + try { + device.putBytesController.status.drop(1).takeWhile { it.state != PutBytesController.State.IDLE }.timeout(15.seconds).collect { + _state.value = AppInstallState.Installing(it.progress) + } + _state.value = AppInstallState.Success + } catch (e: TimeoutCancellationException) { + _state.value = AppInstallState.Error("Timed out while installing") + } + } + } catch (e: Exception) { + _state.value = AppInstallState.Error(e.message ?: "Unknown error") + } + } + } +} \ No newline at end of file diff --git a/android/shared/src/commonMain/kotlin/io/rebble/cobble/shared/util/File.kt b/android/shared/src/commonMain/kotlin/io/rebble/cobble/shared/util/File.kt index 4a9b3050..68effca9 100644 --- a/android/shared/src/commonMain/kotlin/io/rebble/cobble/shared/util/File.kt +++ b/android/shared/src/commonMain/kotlin/io/rebble/cobble/shared/util/File.kt @@ -1,5 +1,8 @@ package io.rebble.cobble.shared.util +import io.ktor.utils.io.ByteReadChannel + expect class File(uri: String) { fun exists(): Boolean + fun readChannel(): ByteReadChannel } \ No newline at end of file diff --git a/android/shared/src/iosMain/kotlin/io/rebble/cobble/shared/domain/pbw/PbwApp.ios.kt b/android/shared/src/iosMain/kotlin/io/rebble/cobble/shared/domain/pbw/PbwApp.ios.kt new file mode 100644 index 00000000..698c07e1 --- /dev/null +++ b/android/shared/src/iosMain/kotlin/io/rebble/cobble/shared/domain/pbw/PbwApp.ios.kt @@ -0,0 +1,8 @@ +package io.rebble.cobble.shared.domain.pbw + +import io.rebble.cobble.shared.PlatformContext +import io.rebble.cobble.shared.util.File + +actual fun getPbwFile(platformContext: PlatformContext, uri: String): File { + TODO("Not yet implemented") +} \ No newline at end of file