diff --git a/android/shared/src/androidMain/kotlin/io/rebble/cobble/shared/js/JsTokenUtil.kt b/android/shared/src/androidMain/kotlin/io/rebble/cobble/shared/js/JsTokenUtil.kt index cc4aeabb..8b0cafe2 100644 --- a/android/shared/src/androidMain/kotlin/io/rebble/cobble/shared/js/JsTokenUtil.kt +++ b/android/shared/src/androidMain/kotlin/io/rebble/cobble/shared/js/JsTokenUtil.kt @@ -4,10 +4,15 @@ import com.benasher44.uuid.Uuid import io.rebble.cobble.shared.api.RWS import io.rebble.cobble.shared.database.dao.LockerDao import io.rebble.cobble.shared.domain.common.PebbleDevice +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withTimeout import org.koin.core.component.KoinComponent import org.koin.core.component.inject import java.security.MessageDigest import java.util.Locale +import kotlin.time.Duration.Companion.seconds object JsTokenUtil: KoinComponent { private val lockerDao: LockerDao by inject() @@ -36,6 +41,22 @@ object JsTokenUtil: KoinComponent { } suspend fun getAccountToken(uuid: Uuid): String? { - return RWS.authClient?.getCurrentAccount()?.uid?.toString()?.let { generateToken(uuid, it) } + return try { + withTimeout(5.seconds) { + RWS.authClientFlow.filterNotNull().first().getCurrentAccount().uid.toString().let { generateToken(uuid, it) } + } + } catch (e: TimeoutCancellationException) { + null + } + } + + suspend fun getSandboxTimelineToken(uuid: Uuid): String? { + return try { + withTimeout(5.seconds) { + RWS.timelineClientFlow.filterNotNull().first().getSandboxUserToken(uuid.toString()) + } + } catch (e: TimeoutCancellationException) { + null + } } } \ No newline at end of file diff --git a/android/shared/src/androidMain/kotlin/io/rebble/cobble/shared/js/WebViewJsRunner.kt b/android/shared/src/androidMain/kotlin/io/rebble/cobble/shared/js/WebViewJsRunner.kt index b1964e53..5e515b0c 100644 --- a/android/shared/src/androidMain/kotlin/io/rebble/cobble/shared/js/WebViewJsRunner.kt +++ b/android/shared/src/androidMain/kotlin/io/rebble/cobble/shared/js/WebViewJsRunner.kt @@ -18,7 +18,7 @@ import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json -class WebViewJsRunner(val context: Context, device: PebbleDevice, private val scope: CoroutineScope, appInfo: PbwAppInfo, jsPath: String): JsRunner(appInfo, jsPath, device) { +class WebViewJsRunner(val context: Context, device: PebbleDevice, val scope: CoroutineScope, appInfo: PbwAppInfo, jsPath: String): JsRunner(appInfo, jsPath, device) { companion object { const val API_NAMESPACE = "Pebble" @@ -238,6 +238,20 @@ class WebViewJsRunner(val context: Context, device: PebbleDevice, private val sc } ?: error("WebView not initialized") } + suspend fun signalTimelineToken(callId: String, token: String) { + val tokenJson = Json.encodeToString(mapOf("userToken" to token, "callId" to callId)) + withContext(Dispatchers.Main) { + webView?.loadUrl("javascript:signalTimelineTokenSuccess('${Uri.encode(tokenJson)}')") + } + } + + suspend fun signalTimelineTokenFail(callId: String) { + val tokenJson = Json.encodeToString(mapOf("userToken" to null, "callId" to callId)) + withContext(Dispatchers.Main) { + webView?.loadUrl("javascript:signalTimelineTokenFailure('${Uri.encode(tokenJson)}')") + } + } + suspend fun signalReady() { val readyDeviceIds = listOf(device.address) val readyJson = Json.encodeToString(readyDeviceIds) diff --git a/android/shared/src/androidMain/kotlin/io/rebble/cobble/shared/js/WebViewPrivatePKJSInterface.kt b/android/shared/src/androidMain/kotlin/io/rebble/cobble/shared/js/WebViewPrivatePKJSInterface.kt index 622b9cf7..69f5a1f7 100644 --- a/android/shared/src/androidMain/kotlin/io/rebble/cobble/shared/js/WebViewPrivatePKJSInterface.kt +++ b/android/shared/src/androidMain/kotlin/io/rebble/cobble/shared/js/WebViewPrivatePKJSInterface.kt @@ -2,9 +2,11 @@ package io.rebble.cobble.shared.js import android.net.Uri import android.webkit.JavascriptInterface +import com.benasher44.uuid.Uuid import io.rebble.cobble.shared.Logging import io.rebble.cobble.shared.data.js.ActivePebbleWatchInfo import io.rebble.cobble.shared.data.js.fromDevice +import io.rebble.cobble.shared.database.dao.LockerDao import io.rebble.cobble.shared.domain.state.ConnectionStateManager import io.rebble.cobble.shared.domain.state.watchOrNull import kotlinx.coroutines.CoroutineScope @@ -12,8 +14,11 @@ import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.launch import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject -class WebViewPrivatePKJSInterface(private val jsRunner: WebViewJsRunner, private val scope: CoroutineScope, private val outgoingAppMessages: MutableSharedFlow): PrivatePKJSInterface { +class WebViewPrivatePKJSInterface(private val jsRunner: WebViewJsRunner, private val scope: CoroutineScope, private val outgoingAppMessages: MutableSharedFlow): PrivatePKJSInterface, KoinComponent { + private val lockerDao: LockerDao by inject() @JavascriptInterface override fun privateLog(message: String) { @@ -41,6 +46,35 @@ class WebViewPrivatePKJSInterface(private val jsRunner: WebViewJsRunner, private Logging.v("logLocationRequest") } + @JavascriptInterface + override fun getTimelineTokenAsync(): String { + val uuid = Uuid.fromString(jsRunner.appInfo.uuid) + jsRunner.scope.launch { + var token: String? = null + val entry = lockerDao.getEntryByUuid(uuid.toString()) + if (entry != null) { + token = entry.entry.userToken + if (entry.entry.local /*&& token == null*/) { + Logging.d("App is local, getting sandbox timeline token") + token = JsTokenUtil.getSandboxTimelineToken(uuid) + if (token == null) { + Logging.w("Failed to get sandbox timeline token") + } else { + lockerDao.update(entry.entry.copy(userToken = token)) + } + } + } else { + Logging.e("App not found in locker") + } + if (token == null) { + jsRunner.signalTimelineTokenFail(uuid.toString()) + } else { + jsRunner.signalTimelineToken(uuid.toString(), token) + } + } + return uuid.toString() + } + @JavascriptInterface fun startupScriptHasLoaded(url: String) { Logging.v("Startup script has loaded: $url") diff --git a/android/shared/src/commonMain/kotlin/io/rebble/cobble/shared/api/RWS.kt b/android/shared/src/commonMain/kotlin/io/rebble/cobble/shared/api/RWS.kt index 9cdb2b37..a79ed420 100644 --- a/android/shared/src/commonMain/kotlin/io/rebble/cobble/shared/api/RWS.kt +++ b/android/shared/src/commonMain/kotlin/io/rebble/cobble/shared/api/RWS.kt @@ -17,14 +17,19 @@ object RWS: KoinComponent { private val token: StateFlow by inject(named("currentToken")) private val scope = CoroutineScope(Dispatchers.Default) - private val _appstoreClient = token.map { + val appstoreClientFlow = token.map { it.tokenOrNull?.let { t -> AppstoreClient("https://appstore-api.$domainSuffix/api", t) } }.stateIn(scope, SharingStarted.Eagerly, null) - private val _authClient = token.map { + val authClientFlow = token.map { it.tokenOrNull?.let { t -> AuthClient("https://auth.$domainSuffix/api", t) } }.stateIn(scope, SharingStarted.Eagerly, null) + val timelineClientFlow = token.map { + it.tokenOrNull?.let { t -> TimelineClient("https://timeline-sync.$domainSuffix", t) } + }.stateIn(scope, SharingStarted.Eagerly, null) val appstoreClient: AppstoreClient? - get() = _appstoreClient.value + get() = appstoreClientFlow.value val authClient: AuthClient? - get() = _authClient.value + get() = authClientFlow.value + val timelineClient: TimelineClient? + get() = timelineClientFlow.value } \ No newline at end of file diff --git a/android/shared/src/commonMain/kotlin/io/rebble/cobble/shared/api/TimelineClient.kt b/android/shared/src/commonMain/kotlin/io/rebble/cobble/shared/api/TimelineClient.kt new file mode 100644 index 00000000..835033d3 --- /dev/null +++ b/android/shared/src/commonMain/kotlin/io/rebble/cobble/shared/api/TimelineClient.kt @@ -0,0 +1,31 @@ +package io.rebble.cobble.shared.api + +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.request.get +import io.ktor.client.request.headers +import io.ktor.http.HttpHeaders +import io.rebble.cobble.shared.domain.api.timeline.TimelineTokenResponse +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject + +class TimelineClient( + val syncBaseUrl: String, + private val token: String +): KoinComponent { + private val client: HttpClient by inject() + private val version = "v1" + suspend fun getSandboxUserToken(uuid: String): String { + val res = client.get("$syncBaseUrl/$version/tokens/sandbox/$uuid") { + headers { + append(HttpHeaders.Accept, "application/json") + append(HttpHeaders.Authorization, "Bearer $token") + } + } + if (res.status.value != 200) { + error("Failed to get sandbox user token: ${res.status}") + } + val body: TimelineTokenResponse = res.body() + return body.token + } +} \ No newline at end of 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 e344bf81..fc101669 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 = 12, + version = 13, autoMigrations = [ AutoMigration(1, 2), AutoMigration(2, 3), @@ -31,7 +31,8 @@ import org.koin.mp.KoinPlatformTools AutoMigration(8, 9), AutoMigration(9, 10), AutoMigration(10, 11), - AutoMigration(11, 12) + AutoMigration(11, 12), + AutoMigration(12, 13) ] ) @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 6e0facd9..dc8b26e6 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 @@ -34,7 +34,9 @@ data class SyncedLockerEntry( val order: Int, val lastOpened: Instant?, @ColumnInfo(defaultValue = "0") - val local: Boolean = false + val local: Boolean = false, + @ColumnInfo(defaultValue = "null") + val userToken: String? = null, ) data class SyncedLockerEntryWithPlatforms( diff --git a/android/shared/src/commonMain/kotlin/io/rebble/cobble/shared/domain/api/appstore/LockerEntry.kt b/android/shared/src/commonMain/kotlin/io/rebble/cobble/shared/domain/api/appstore/LockerEntry.kt index 28fb66ec..656d0602 100644 --- a/android/shared/src/commonMain/kotlin/io/rebble/cobble/shared/domain/api/appstore/LockerEntry.kt +++ b/android/shared/src/commonMain/kotlin/io/rebble/cobble/shared/domain/api/appstore/LockerEntry.kt @@ -123,7 +123,8 @@ fun LockerEntry.toEntity(): SyncedLockerEntry { pbwIconResourceId = pbw.iconResourceId, nextSyncAction = if (type == "watchface") NextSyncAction.Ignore else NextSyncAction.Upload, order = -1, - lastOpened = null + lastOpened = null, + userToken = userToken ) } diff --git a/android/shared/src/commonMain/kotlin/io/rebble/cobble/shared/domain/api/timeline/TimelineTokenResponse.kt b/android/shared/src/commonMain/kotlin/io/rebble/cobble/shared/domain/api/timeline/TimelineTokenResponse.kt new file mode 100644 index 00000000..290ddaab --- /dev/null +++ b/android/shared/src/commonMain/kotlin/io/rebble/cobble/shared/domain/api/timeline/TimelineTokenResponse.kt @@ -0,0 +1,9 @@ +package io.rebble.cobble.shared.domain.api.timeline + +import kotlinx.serialization.Serializable + +@Serializable +data class TimelineTokenResponse( + val token: String, + val uuid: String +) \ No newline at end of file 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 index d6ceee38..acb930b9 100644 --- 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 @@ -55,7 +55,8 @@ class PbwApp(uri: String): KoinComponent { NextSyncAction.Upload, order = -1, lastOpened = null, - local = true + local = true, + userToken = null ) ) diff --git a/android/shared/src/commonMain/kotlin/io/rebble/cobble/shared/js/PrivatePKJSInterface.kt b/android/shared/src/commonMain/kotlin/io/rebble/cobble/shared/js/PrivatePKJSInterface.kt index f57850b1..f6505833 100644 --- a/android/shared/src/commonMain/kotlin/io/rebble/cobble/shared/js/PrivatePKJSInterface.kt +++ b/android/shared/src/commonMain/kotlin/io/rebble/cobble/shared/js/PrivatePKJSInterface.kt @@ -6,4 +6,5 @@ interface PrivatePKJSInterface { fun logInterceptedRequest() fun logLocationRequest() fun getVersionCode(): Int + fun getTimelineTokenAsync(): String } \ No newline at end of file