diff --git a/Manager/app/build.gradle.kts b/Manager/app/build.gradle.kts index f81a5e7..15d5c30 100644 --- a/Manager/app/build.gradle.kts +++ b/Manager/app/build.gradle.kts @@ -190,6 +190,7 @@ dependencies { implementation(libs.bundles.ktor) implementation(libs.bundles.shizuku) implementation(libs.bundles.voyager) + implementation(libs.pebblekit2.client) implementation(libs.compose.ui.tooling.preview) debugImplementation(libs.compose.ui.tooling) diff --git a/Manager/app/src/main/AndroidManifest.xml b/Manager/app/src/main/AndroidManifest.xml index 0b1977d..e148cef 100644 --- a/Manager/app/src/main/AndroidManifest.xml +++ b/Manager/app/src/main/AndroidManifest.xml @@ -43,6 +43,22 @@ android:name=".installers.pm.PMIntentReceiver" android:exported="false" /> + + + + + + + + + + ().specs .firstOrNull { it.id == "WazeIntegration" }?.defaultEnabled ?: false val enableWazeIntegration = options.patchStates["WazeIntegration"] ?: wazeDefault + val pebbleDefault = container.getStep().specs + .firstOrNull { it.id == "PebbleIntegration" }?.defaultEnabled ?: false + val enablePebbleIntegration = options.patchStates["PebbleIntegration"] ?: pebbleDefault val patchedManifest = ManifestPatcher.patchManifest( manifestBytes = manifest, @@ -36,6 +39,7 @@ class PatchManifestStep(private val options: PatchOptions) : Step() { appName = options.appName, debuggable = options.debuggable, enableWazeIntegration = enableWazeIntegration, + enablePebbleIntegration = enablePebbleIntegration, ) container.log("Repacking apk with patched manifest") diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/patcher/util/ManifestPatcher.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/patcher/util/ManifestPatcher.kt index 81a625e..4aea970 100644 --- a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/patcher/util/ManifestPatcher.kt +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/patcher/util/ManifestPatcher.kt @@ -21,6 +21,8 @@ object ManifestPatcher { private const val WAZE_PACKAGE = "com.waze" private const val WAZE_INIT_RECEIVER = "radiant.WazeInitReceiver" private const val WAZE_ACTION_INIT = "com.waze.sdk.audio.ACTION_INIT" + // Pebble bridge binds to the Manager + private const val MANAGER_PACKAGE = "com.meowarex.rlmobile" fun patchManifest( manifestBytes: ByteArray, @@ -28,6 +30,7 @@ object ManifestPatcher { appName: String, debuggable: Boolean, enableWazeIntegration: Boolean, + enablePebbleIntegration: Boolean, ): ByteArray { // Extract original package name to rewrite every reference to it // (permissions, provider authorities) to the new packageName. @@ -90,11 +93,12 @@ object ManifestPatcher { return when (name) { "queries" -> object : NodeVisitor(nv) { private var hasWazePackage = false + private var hasManagerPackage = false override fun child(ns: String?, name: String): NodeVisitor { val visitor = super.child(ns, name) - return if (enableWazeIntegration && name == "package") { + return if ((enableWazeIntegration || enablePebbleIntegration) && name == "package") { object : NodeVisitor(visitor) { override fun attr( ns: String?, @@ -106,6 +110,9 @@ object ManifestPatcher { if (name == "name" && value == WAZE_PACKAGE) { hasWazePackage = true } + if (name == "name" && value == MANAGER_PACKAGE) { + hasManagerPackage = true + } super.attr(ns, name, resourceId, type, value) } } @@ -127,6 +134,18 @@ object ManifestPatcher { end() } } + if (enablePebbleIntegration && !hasManagerPackage) { + super.child(null, "package").apply { + attr( + ANDROID_NAMESPACE, + "name", + android.R.attr.name, + TYPE_STRING, + MANAGER_PACKAGE, + ) + end() + } + } super.end() } } diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/pebble/PebbleRelayService.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/pebble/PebbleRelayService.kt new file mode 100644 index 0000000..5037683 Binary files /dev/null and b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/pebble/PebbleRelayService.kt differ diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/pebble/PebbleWatchListenerService.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/pebble/PebbleWatchListenerService.kt new file mode 100644 index 0000000..1d1d063 --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/pebble/PebbleWatchListenerService.kt @@ -0,0 +1,40 @@ +package com.meowarex.rlmobile.pebble + +import android.os.SystemClock +import io.rebble.pebblekit2.client.BasePebbleListenerService +import io.rebble.pebblekit2.common.model.PebbleDictionary +import io.rebble.pebblekit2.common.model.ReceiveResult +import io.rebble.pebblekit2.common.model.WatchIdentifier +import java.util.UUID + +// Forwards watch events to the relay +class PebbleWatchListenerService : BasePebbleListenerService() { + override suspend fun onMessageReceived( + watchappUUID: UUID, + data: PebbleDictionary, + watch: WatchIdentifier, + ): ReceiveResult { + val receivedAt = SystemClock.elapsedRealtime() + if (watchappUUID != WatchProtocol.APP_UUID) return ReceiveResult.Nack + PebbleRelayService.current?.onWatchMessage(WatchProtocol.ints(data), receivedAt) + return ReceiveResult.Ack + } + + override fun onAppOpened(watchappUUID: UUID, watch: WatchIdentifier) { + if (watchappUUID != WatchProtocol.APP_UUID) return + watchappOpen = true + PebbleRelayService.current?.onWatchappOpened() + } + + override fun onAppClosed(watchappUUID: UUID, watch: WatchIdentifier) { + if (watchappUUID != WatchProtocol.APP_UUID) return + watchappOpen = false + PebbleRelayService.current?.onWatchappClosed() + } + + companion object { + @Volatile + var watchappOpen = false + private set + } +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/pebble/PebbleWatchapp.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/pebble/PebbleWatchapp.kt new file mode 100644 index 0000000..32acf51 --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/pebble/PebbleWatchapp.kt @@ -0,0 +1,61 @@ +package com.meowarex.rlmobile.pebble + +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import android.net.Uri +import com.meowarex.rlmobile.R +import com.meowarex.rlmobile.ui.screens.patchopts.CompanionAppSpec +import com.meowarex.rlmobile.util.showToast +import io.rebble.pebblekit2.client.DefaultPebbleSender +import io.rebble.pebblekit2.common.model.TimelineResult + +// Watchapp install state and store links +object PebbleWatchapp { + private const val PROBE_PIN_ID = "rl-install-probe" + private const val STORE_WEB = "https://apps.repebble.com/" + private const val APPS_TAB = "pebble://navbar/index" + + enum class InstallState { Installed, NotInstalled, Unknown } + + // Deleting a missing pin reveals install state + suspend fun installState(context: Context): InstallState = runCatching { + DefaultPebbleSender(context.applicationContext).use { sender -> + when (sender.deleteTimelinePin(WatchProtocol.APP_UUID, PROBE_PIN_ID)) { + TimelineResult.FailedUnknownPin, TimelineResult.Success -> InstallState.Installed + TimelineResult.FailedNoPermissions -> InstallState.NotInstalled + else -> InstallState.Unknown + } + } + }.getOrDefault(InstallState.Unknown) + + fun canInstall(app: CompanionAppSpec): Boolean = !app.storeId.isNullOrBlank() + + fun install(context: Context, app: CompanionAppSpec) = openStorePage(context, app) + + // Apps tab, My Apps holds the settings + fun openSettings(context: Context) { + if (!start(context, Intent(Intent.ACTION_VIEW, Uri.parse(APPS_TAB)))) { + context.showToast(R.string.pebble_app_missing) + } + } + + // Pebble app store page, else the web listing + private fun openStorePage(context: Context, app: CompanionAppSpec) { + val storeId = app.storeId?.takeIf { it.isNotBlank() } ?: return + val deepLink = Uri.Builder().scheme("pebble").authority("appstore").appendPath(storeId) + .apply { app.storeSource?.let { appendQueryParameter("source", it) } } + .build() + if (start(context, Intent(Intent.ACTION_VIEW, deepLink))) return + if (!start(context, Intent(Intent.ACTION_VIEW, Uri.parse(STORE_WEB + storeId)))) { + context.showToast(R.string.pebble_app_missing) + } + } + + private fun start(context: Context, intent: Intent): Boolean = try { + context.startActivity(intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) + true + } catch (_: ActivityNotFoundException) { + false + } +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/pebble/WatchProtocol.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/pebble/WatchProtocol.kt new file mode 100644 index 0000000..f219266 --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/pebble/WatchProtocol.kt @@ -0,0 +1,265 @@ +package com.meowarex.rlmobile.pebble + +import io.rebble.pebblekit2.common.model.PebbleDictionary +import io.rebble.pebblekit2.common.model.PebbleDictionaryItem +import org.json.JSONArray +import org.json.JSONObject +import java.io.ByteArrayOutputStream +import java.util.UUID +import kotlin.math.roundToLong + +// Mirrors rl-pebble main.c and emu_driver.py +object WatchProtocol { + val APP_UUID: UUID = UUID.fromString("79d7fe6a-b970-483a-b096-16da10af0433") + + // package.json messageKeys order + const val KEY_CMD = 10000 + const val KEY_TRACK_ID = 10001 + const val KEY_TITLE = 10002 + const val KEY_ARTIST = 10003 + const val KEY_DURATION = 10004 + const val KEY_STATUS = 10005 + const val KEY_LINE_COUNT = 10006 + const val KEY_CHUNK_INDEX = 10007 + const val KEY_CHUNK = 10008 + const val KEY_PLAYING = 10009 + const val KEY_POSITION = 10010 + const val KEY_INBOX_SIZE = 10011 + const val KEY_PHONE_TIME = 10012 + const val KEY_WATCH_TIME = 10013 + const val KEY_PHONE_TIME_2 = 10014 + const val KEY_SET_AUTO_OPEN = 10021 + + const val CMD_TRACK = 1 + const val CMD_LINES = 2 + const val CMD_STATE = 3 + const val CMD_STATUS = 4 + const val CMD_SYNC = 5 + const val CMD_HELLO = 20 + const val CMD_PLAY_PAUSE = 21 + const val CMD_NEXT = 22 + const val CMD_PREV = 23 + const val CMD_SYNC_REQ = 24 + const val CMD_SETTINGS = 25 + + const val STATUS_IDLE = 0 + const val STATUS_LOADING = 1 + const val STATUS_READY = 2 + const val STATUS_NO_LYRICS = 3 + const val STATUS_ERROR = 4 + + private const val SEG_BG = 1 shl 0 + private const val SEG_SPACE = 1 shl 1 + private const val LINE_RIGHT = 1 shl 0 + + private val WHITESPACE = Regex("\\s+") + private const val HOLD_FOREVER = Int.MAX_VALUE.toLong() + private const val LINE_ONLY_MS = 800L + private const val MAX_SEGMENTS = 120 + + class Segment(val time: Long, val duration: Long, val flags: Int, val text: String) + + // end is when the line stops being lit + class Line(val start: Long, val end: Long, val segments: List, val right: Boolean) + + // Syllable level lines, lit until the next lead line. + fun shape(body: String): List { + val root = JSONObject(body) + val data = root.optJSONArray("data") ?: return emptyList() + val sides = singerSides(root, data) + + class Raw(val start: Long, val end: Long, val allBackground: Boolean, val segments: List, val right: Boolean) + + val raws = ArrayList(data.length()) + for (i in 0 until data.length()) { + val item = data.optJSONObject(i) ?: continue + val syllabus = item.optJSONArray("syllabus")?.takeIf { it.length() > 0 } + + val segments = ArrayList() + val start: Long + val end: Long + var allBackground = false + if (syllabus != null) { + val first = syllabus.optJSONObject(0) + val last = syllabus.optJSONObject(syllabus.length() - 1) + start = first?.optLong("time") ?: 0 + end = (last?.optLong("time") ?: 0) + (last?.optLong("duration") ?: 0) + allBackground = (0 until syllabus.length()).all { + syllabus.optJSONObject(it)?.optBoolean("isBackground") == true + } + for (j in 0 until syllabus.length()) { + val syllable = syllabus.optJSONObject(j) ?: continue + val raw = text(syllable) + val trimmed = raw.trim() + if (trimmed.isEmpty()) continue + var flags = if (syllable.optBoolean("isBackground")) SEG_BG else 0 + if (raw != raw.trimEnd()) flags = flags or SEG_SPACE + segments += Segment(syllable.optLong("time"), syllable.optLong("duration"), flags, trimmed) + } + } else { + // Line timed only, one segment per word + start = (item.optDouble("startTime", 0.0) * 1000).roundToLong() + end = start + LINE_ONLY_MS + val words = text(item).split(WHITESPACE).filter { it.isNotEmpty() } + words.forEachIndexed { index, word -> + segments += Segment(start, 0, if (index < words.lastIndex) SEG_SPACE else 0, word) + } + } + if (segments.isEmpty()) continue + raws += Raw(start, end, allBackground, segments.take(MAX_SEGMENTS), sides?.get(i) == 1) + } + + return raws.mapIndexed { index, raw -> + val nextLead = (index + 1 until raws.size).firstOrNull { !raws[it].allBackground }?.let { raws[it].start } + Line(raw.start, (nextLead ?: HOLD_FOREVER).coerceAtLeast(raw.end), raw.segments, raw.right) + } + } + + // RL Mobile duet sides, null for one side. + private fun singerSides(root: JSONObject, data: JSONArray): IntArray? { + val agents = root.optJSONObject("metadata")?.optJSONObject("agents") + fun singerOf(i: Int) = data.optJSONObject(i)?.optJSONObject("element")?.optString("singer")?.takeIf { it.isNotEmpty() } + fun typeOf(singer: String): String = agents?.optJSONObject(singer)?.optString("type", "person") + ?: when (singer) { + "v1000" -> "group" + "v2000" -> "other" + else -> "person" + } + + // Groups left, second person right + val sideOf = HashMap() + var persons = 0 + for (i in 0 until data.length()) { + val singer = singerOf(i) ?: continue + if (singer in sideOf) continue + sideOf[singer] = if (typeOf(singer) != "person") 0 else if (++persons == 2) 1 else 0 + } + + val sides = IntArray(data.length()) { i -> singerOf(i)?.let { sideOf[it] ?: 0 } ?: -1 } + val sided = sides.count { it >= 0 } + val right = sides.count { it == 1 } + // Nearly all right, swap + if (sided > 0 && right * 100 / sided >= 85) { + for (i in sides.indices) if (sides[i] >= 0) sides[i] = 1 - sides[i] + } + return if (sides.any { it == 0 } && sides.any { it == 1 }) sides else null + } + + private fun text(obj: JSONObject): String = + obj.optString("romanized").takeIf { it.isNotBlank() } ?: obj.optString("text") + + private fun encodeLine(line: Line): ByteArray { + val out = ByteArrayOutputStream() + out.u32(line.start) + out.u32(line.end) + out.write(line.segments.size) + out.write(if (line.right) LINE_RIGHT else 0) + for (segment in line.segments) { + val text = utf8Truncate(segment.text, 255) + out.u16((segment.time - line.start).coerceIn(0, 65535)) + out.u16(segment.duration.coerceIn(0, 65535)) + out.write(segment.flags) + out.write(text.size) + out.write(text) + } + return out.toByteArray() + } + + // Whole lines per chunk, keyed by first index. + fun chunks(lines: List, budget: Int): List> { + val result = ArrayList>() + val buf = ByteArrayOutputStream() + var first = 0 + lines.forEachIndexed { index, line -> + val encoded = encodeLine(line) + if (buf.size() > 0 && buf.size() + encoded.size > budget) { + result += first to buf.toByteArray() + buf.reset() + first = index + } + buf.write(encoded) + } + if (buf.size() > 0) result += first to buf.toByteArray() + return result + } + + fun track(trackId: Long, title: String, artist: String, durationMs: Long, status: Int, lineCount: Int): PebbleDictionary = + mapOf( + key(KEY_CMD) to PebbleDictionaryItem.UInt8(CMD_TRACK), + key(KEY_TRACK_ID) to PebbleDictionaryItem.UInt32(trackId), + key(KEY_TITLE) to PebbleDictionaryItem.Text(String(utf8Truncate(title, 60))), + key(KEY_ARTIST) to PebbleDictionaryItem.Text(String(utf8Truncate(artist, 60))), + key(KEY_DURATION) to PebbleDictionaryItem.UInt32(durationMs.coerceIn(0, 0xFFFFFFFFL)), + key(KEY_STATUS) to PebbleDictionaryItem.UInt8(status), + key(KEY_LINE_COUNT) to PebbleDictionaryItem.UInt16(lineCount), + ) + + fun lines(trackId: Long, firstIndex: Int, blob: ByteArray): PebbleDictionary = mapOf( + key(KEY_CMD) to PebbleDictionaryItem.UInt8(CMD_LINES), + key(KEY_TRACK_ID) to PebbleDictionaryItem.UInt32(trackId), + key(KEY_CHUNK_INDEX) to PebbleDictionaryItem.UInt16(firstIndex), + key(KEY_CHUNK) to PebbleDictionaryItem.Bytes(blob), + ) + + // Position at a phone elapsedRealtime. + fun state(trackId: Long, playing: Boolean, positionMs: Long, phoneTimeMs: Long): PebbleDictionary = mapOf( + key(KEY_CMD) to PebbleDictionaryItem.UInt8(CMD_STATE), + key(KEY_TRACK_ID) to PebbleDictionaryItem.UInt32(trackId), + key(KEY_PLAYING) to PebbleDictionaryItem.UInt8(if (playing) 1 else 0), + key(KEY_POSITION) to PebbleDictionaryItem.Int32(positionMs.coerceIn(0, Int.MAX_VALUE.toLong()).toInt()), + key(KEY_PHONE_TIME) to PebbleDictionaryItem.UInt32(phoneTimeMs and 0xFFFFFFFFL), + ) + + fun sync(watchTime: Long, receivedAt: Long, sentAt: Long): PebbleDictionary = mapOf( + key(KEY_CMD) to PebbleDictionaryItem.UInt8(CMD_SYNC), + key(KEY_WATCH_TIME) to PebbleDictionaryItem.UInt32(watchTime and 0xFFFFFFFFL), + key(KEY_PHONE_TIME) to PebbleDictionaryItem.UInt32(receivedAt and 0xFFFFFFFFL), + key(KEY_PHONE_TIME_2) to PebbleDictionaryItem.UInt32(sentAt and 0xFFFFFFFFL), + ) + + fun status(trackId: Long, status: Int): PebbleDictionary = mapOf( + key(KEY_CMD) to PebbleDictionaryItem.UInt8(CMD_STATUS), + key(KEY_TRACK_ID) to PebbleDictionaryItem.UInt32(trackId), + key(KEY_STATUS) to PebbleDictionaryItem.UInt8(status), + ) + + // Integer tuples from the watch. + fun ints(data: PebbleDictionary): Map = buildMap { + for ((k, item) in data) { + val value = when (item) { + is PebbleDictionaryItem.UInt32 -> item.value.toLong() + is PebbleDictionaryItem.Int32 -> item.value.toLong() + is PebbleDictionaryItem.UInt16 -> item.value.toLong() + is PebbleDictionaryItem.Int16 -> item.value.toLong() + is PebbleDictionaryItem.UInt8 -> item.value.toLong() + is PebbleDictionaryItem.Int8 -> item.value.toLong() + else -> continue + } + put(k.toInt(), value) + } + } + + private fun key(key: Int) = key.toUInt() + + private fun utf8Truncate(text: String, limit: Int): ByteArray { + val raw = text.toByteArray(Charsets.UTF_8) + if (raw.size <= limit) return raw + var end = limit + // Don't split a code point + while (end > 0 && (raw[end].toInt() and 0xC0) == 0x80) end-- + return raw.copyOf(end) + } + + private fun ByteArrayOutputStream.u16(value: Long) { + write((value and 0xFF).toInt()) + write(((value shr 8) and 0xFF).toInt()) + } + + private fun ByteArrayOutputStream.u32(value: Long) { + val v = value.coerceIn(0, 0xFFFFFFFFL) + write((v and 0xFF).toInt()) + write(((v shr 8) and 0xFF).toInt()) + write(((v shr 16) and 0xFF).toInt()) + write(((v shr 24) and 0xFF).toInt()) + } +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/legacy/screens/patchopts/components/PatchSelectionAccordion.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/legacy/screens/patchopts/components/PatchSelectionAccordion.kt index 4d4d296..efc00bd 100644 --- a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/legacy/screens/patchopts/components/PatchSelectionAccordion.kt +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/legacy/screens/patchopts/components/PatchSelectionAccordion.kt @@ -22,13 +22,18 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.rotate +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.fromHtml import androidx.compose.ui.unit.dp +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.repeatOnLifecycle import com.meowarex.rlmobile.R +import com.meowarex.rlmobile.pebble.PebbleWatchapp import com.meowarex.rlmobile.ui.screens.patchopts.OptionSpec import com.meowarex.rlmobile.ui.screens.patchopts.PatchLock import com.meowarex.rlmobile.ui.screens.patchopts.PatchOptionState @@ -135,7 +140,9 @@ fun PatchSelectionAccordion( val inlineOptions = patch.advancedOptions.filter { it.isInline } val hasSheetOptions = patch.advancedOptions.any { !it.isInline } - val hasSettings = patch.variants.isNotEmpty() || inlineOptions.isNotEmpty() || hasSheetOptions + val companionApp = patch.companionApp + val hasSettings = patch.variants.isNotEmpty() || inlineOptions.isNotEmpty() || hasSheetOptions || + companionApp != null // When a patch is enabled and has settings show a carded view of the settings val carded = checked && hasSettings @@ -240,6 +247,56 @@ fun PatchSelectionAccordion( ) } } + + if (companionApp != null) { + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + var installState by remember { mutableStateOf(PebbleWatchapp.InstallState.Unknown) } + // Recheck on resume + LaunchedEffect(lifecycleOwner) { + lifecycleOwner.repeatOnLifecycle(Lifecycle.State.RESUMED) { + installState = PebbleWatchapp.installState(context) + } + } + val installed = installState == PebbleWatchapp.InstallState.Installed + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally), + modifier = Modifier.fillMaxWidth(), + ) { + FilledTonalButton( + onClick = { PebbleWatchapp.install(context, companionApp) }, + enabled = !installed && PebbleWatchapp.canInstall(companionApp), + modifier = Modifier.weight(1f), + ) { + Icon( + painter = painterResource(if (installed) R.drawable.ic_check_circle else R.drawable.ic_download), + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(Modifier.width(8.dp)) + Text( + if (installed) { + stringResource(R.string.patchopts_companion_installed) + } else { + companionApp.installTitle.ifBlank { stringResource(R.string.patchopts_companion_install) } + } + ) + } + FilledTonalButton( + onClick = { PebbleWatchapp.openSettings(context) }, + enabled = installed, + modifier = Modifier.weight(1f), + ) { + Icon( + painter = painterResource(R.drawable.ic_tune), + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(Modifier.width(8.dp)) + Text(companionApp.customizeTitle.ifBlank { stringResource(R.string.patchopts_companion_customize) }) + } + } + } } } } diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patchopts/PatchSpec.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patchopts/PatchSpec.kt index 120b382..95a8afd 100644 --- a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patchopts/PatchSpec.kt +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patchopts/PatchSpec.kt @@ -34,6 +34,8 @@ data class PatchSpec( val advancedOptions: List = emptyList(), val category: String = CATEGORY_PATCH, val pathLocked: Boolean = false, + /** Watchapp shown under an integration. */ + val companionApp: CompanionAppSpec? = null, ) { val isIntegration: Boolean get() = category == CATEGORY_INTEGRATION @@ -43,6 +45,17 @@ data class PatchSpec( } } +@Immutable +@Serializable +data class CompanionAppSpec( + val installTitle: String = "", + val customizeTitle: String = "", + /** Pebble appstore id. */ + val storeId: String? = null, + /** Appstore feed url. */ + val storeSource: String? = null, +) + @Immutable @Serializable data class VariantSpec( diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patchopts/components/PatchSelectionAccordion.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patchopts/components/PatchSelectionAccordion.kt index 0bcde3a..7b154d9 100644 --- a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patchopts/components/PatchSelectionAccordion.kt +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patchopts/components/PatchSelectionAccordion.kt @@ -19,13 +19,18 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.rotate +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.fromHtml import androidx.compose.ui.unit.dp +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.repeatOnLifecycle import com.meowarex.rlmobile.R +import com.meowarex.rlmobile.pebble.PebbleWatchapp import com.meowarex.rlmobile.ui.components.radiant.RadiantButton import com.meowarex.rlmobile.ui.components.radiant.RadiantButtonSize import com.meowarex.rlmobile.ui.components.radiant.RadiantSwitch @@ -138,7 +143,9 @@ fun PatchSelectionAccordion( val inlineOptions = patch.advancedOptions.filter { it.isInline } val hasSheetOptions = patch.advancedOptions.any { !it.isInline } - val hasSettings = patch.variants.isNotEmpty() || inlineOptions.isNotEmpty() || hasSheetOptions + val companionApp = patch.companionApp + val hasSettings = patch.variants.isNotEmpty() || inlineOptions.isNotEmpty() || hasSheetOptions || + companionApp != null // When a patch is enabled and has settings show a carded view of the settings val carded = checked && hasSettings @@ -238,6 +245,47 @@ fun PatchSelectionAccordion( ) } } + + if (companionApp != null) { + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + var installState by remember { mutableStateOf(PebbleWatchapp.InstallState.Unknown) } + // Recheck on resume + LaunchedEffect(lifecycleOwner) { + lifecycleOwner.repeatOnLifecycle(Lifecycle.State.RESUMED) { + installState = PebbleWatchapp.installState(context) + } + } + val installed = installState == PebbleWatchapp.InstallState.Installed + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally), + modifier = Modifier.fillMaxWidth(), + ) { + RadiantButton( + text = if (installed) { + stringResource(R.string.patchopts_companion_installed) + } else { + companionApp.installTitle.ifBlank { stringResource(R.string.patchopts_companion_install) } + }, + icon = painterResource(if (installed) R.drawable.ic_check_circle else R.drawable.ic_download), + onClick = { PebbleWatchapp.install(context, companionApp) }, + enabled = !installed && PebbleWatchapp.canInstall(companionApp), + size = RadiantButtonSize.Small, + fillWidth = true, + modifier = Modifier.weight(1f), + ) + RadiantButton( + text = companionApp.customizeTitle + .ifBlank { stringResource(R.string.patchopts_companion_customize) }, + icon = painterResource(R.drawable.ic_tune), + onClick = { PebbleWatchapp.openSettings(context) }, + enabled = installed, + size = RadiantButtonSize.Small, + fillWidth = true, + modifier = Modifier.weight(1f), + ) + } + } } } } diff --git a/Manager/app/src/main/res/values/strings.xml b/Manager/app/src/main/res/values/strings.xml index 54aba54..2066069 100644 --- a/Manager/app/src/main/res/values/strings.xml +++ b/Manager/app/src/main/res/values/strings.xml @@ -85,6 +85,10 @@ Installation Appearance Info + Install watchapp + Installed + Customize + Couldn\'t open the Pebble app. Is it installed? Theme UI style Want the old UI back? diff --git a/Manager/gradle/libs.versions.toml b/Manager/gradle/libs.versions.toml index 9c8ebc3..f5970e1 100644 --- a/Manager/gradle/libs.versions.toml +++ b/Manager/gradle/libs.versions.toml @@ -32,6 +32,7 @@ kotlinx-serialization = "1.10.0" ktor = "3.4.0" libsu = "6.0.0" microg = "0.3.6.244735" +pebblekit2 = "1.3.1" shimmer = "1.3.3" shizuku = "13.1.5" smali = "3.0.9" @@ -115,6 +116,7 @@ hiddenApi-stub = { module = "dev.rikka.hidden:stub", version.ref = "hiddenApi-st microg = { module = "org.microg.gms:play-services-safetynet", version.ref = "microg" } desugaring = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugaring" } libsu = { module = "com.aliucord.libsu:core", version.ref = "libsu" } +pebblekit2-client = { module = "io.rebble.pebblekit2:client", version.ref = "pebblekit2" } [bundles] accompanist = [ diff --git a/README.md b/README.md index 96361bd..a04151d 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ Every Patch & Integration can be disabled/enabled & adjusted exactly how you wan ### Integrations - Waze | Control TIDAL playback from Waze +- Pebble Watch | Synced lyrics (Line, Word or Syllable) & playback controls on your Pebble ### Disclaimer diff --git a/patches/R8-SYMBOLS.md b/patches/R8-SYMBOLS.md index 87c9eb2..a240fcf 100644 --- a/patches/R8-SYMBOLS.md +++ b/patches/R8-SYMBOLS.md @@ -49,7 +49,15 @@ That script regenerates `extension/radiant/Kawarp.smali` and The Java side under `tools/kawarp-build/{src,stubs}` is not regenerated and keeps its markers. -## 1. Rolled class references (268) +## Gotcha: Pebble integration hooks + +`extension/radiant/PebbleBridge*.smali` is built by `tools/pebble-bridge-build/build.sh`. + +- `integration-pebble-playback.patch` hooks `bk0/e`, TIDAL's 200 ms position poller. + It holds `Lbk0/d;`, `ExoPlayer` and `Handler` and reposts with `0xc8`. +- `integration-pebble-lyrics.patch` hooks our `RLAPILyricsWorker`, so no markers there. + +## 1. Rolled class references (270) | symbol | uses | referenced from | |---|---:|---| @@ -298,6 +306,8 @@ markers. | `Lxf/d$a;` | 2 | `extension/radiant/gestures/queue/SearchPlaylistsResolver.smali` | | `La30/b;` | 1 | `player-favorite-heart.patch` | | `Landroidx/compose/foundation/text/input/a;` | 1 | `player-backdrop-playback.patch` | +| `Lbk0/d;` | 1 | `integration-pebble-playback.patch` | +| `Lbk0/e;` | 1 | `integration-pebble-playback.patch` | | `Lcom/aspiro/wamp/mycollection/subpages/favoritetracks/n;` | 1 | `gestures/queue/swipe-to-queue.patch` | | `Lcom/aspiro/wamp/nowplaying/view/playqueue/l;` | 1 | `gestures/queue/swipe-to-queue.patch` | | `Lcom/aspiro/wamp/nowplaying/view/playqueue/touchmanagement/a;` | 1 | `gestures/queue/swipe-to-queue.patch` | @@ -676,12 +686,13 @@ The owner name survives R8; only the member name rolls. Match on the descriptor. | `Lz70/a;` | `c` | `:Lcom/aspiro/wamp/model/AvailabilityInteractor;` | 1 | | `Lzf/c;` | `c` | `:Lcom/tidal/android/navigation/NavigationInfo;` | 1 | -## 3. Rolled patch target classes (64) +## 3. Rolled patch target classes (65) `--- a/` headers naming an obfuscated class. If these move the hunk cannot even be located. | target | hooked by | |---|---| +| `bk0/e.smali` | `integration-pebble-playback.patch` | | `cg0/h.smali` | `lyrics-rl-api-isrc.patch` | | `com/aspiro/wamp/mycollection/subpages/albums/myalbums/MyAlbumsView.smali` | `gestures/queue/swipe-to-queue.patch` | | `com/aspiro/wamp/mycollection/subpages/albums/search/SearchAlbumsView.smali` | `gestures/queue/swipe-to-queue.patch` | @@ -747,7 +758,7 @@ The owner name survives R8; only the member name rolls. Match on the descriptor. | `p7/a.smali` | `gestures/queue/swipe-to-queue.patch` | | `v6/e.smali` | `gestures/queue/swipe-to-queue.patch` | -## 4. Occurrences inside .patch files - not marked inline (220) +## 4. Occurrences inside .patch files - not marked inline (221) | file | line | kind | symbols | |---|---:|---|---| @@ -883,6 +894,7 @@ The owner name survives R8; only the member name rolls. Match on the descriptor. | `gestures/queue/swipe-to-queue.patch` | 469 | context | `Lcom/tidal/android/feature/appscaffold/ui/recyclerview/b; Lcom/tidal/android/feature/appscaffold/ui/recyclerview/a; a` | | `gestures/queue/swipe-to-queue.patch` | 482 | context | `Lcom/tidal/android/feature/appscaffold/ui/recyclerview/b; Lcom/tidal/android/feature/appscaffold/ui/recyclerview/a; a` | | `gestures/queue/swipe-to-queue.patch` | 500 | context | `Lcom/aspiro/wamp/nowplaying/view/suggestions/o0; Lcom/aspiro/wamp/nowplaying/view/suggestions/l; M` | +| `integration-pebble-playback.patch` | 30 | context | `Lbk0/d; Lbk0/e;` | | `integration-waze-playback.patch` | 9 | added | `Lkotlin/jvm/internal/n;` | | `integration-waze-playback.patch` | 17 | added | `Lkotlin/jvm/internal/n;` | | `integration-waze-playback.patch` | 35 | added | `Lkotlin/jvm/internal/n;` | diff --git a/patches/extension/radiant/PebbleBridge$1.smali b/patches/extension/radiant/PebbleBridge$1.smali new file mode 100644 index 0000000..3798be0 --- /dev/null +++ b/patches/extension/radiant/PebbleBridge$1.smali @@ -0,0 +1,56 @@ +.class Lradiant/PebbleBridge$1; +.super Ljava/lang/Object; + +# interfaces +.implements Ljava/lang/Runnable; + + +# annotations +.annotation system Ldalvik/annotation/EnclosingMethod; + value = Lradiant/PebbleBridge;->onPosition(J)V +.end annotation + +.annotation system Ldalvik/annotation/InnerClass; + accessFlags = 0x0 + name = null +.end annotation + + +# instance fields +.field final synthetic val$at:J + +.field final synthetic val$position:J + + +# direct methods +.method constructor (JJ)V + .registers 5 + .annotation system Ldalvik/annotation/Signature; + value = { + "()V" + } + .end annotation + + iput-wide p1, p0, Lradiant/PebbleBridge$1;->val$position:J + + iput-wide p3, p0, Lradiant/PebbleBridge$1;->val$at:J + + invoke-direct {p0}, Ljava/lang/Object;->()V + + return-void +.end method + + +# virtual methods +.method public run()V + .registers 5 + + iget-wide v0, p0, Lradiant/PebbleBridge$1;->val$position:J + + iget-wide v2, p0, Lradiant/PebbleBridge$1;->val$at:J + + # invokes: Lradiant/PebbleBridge;->onSample(JJ)V + invoke-static {v0, v1, v2, v3}, Lradiant/PebbleBridge;->access$000(JJ)V + + return-void +.end method diff --git a/patches/extension/radiant/PebbleBridge$2.smali b/patches/extension/radiant/PebbleBridge$2.smali new file mode 100644 index 0000000..8d788a5 --- /dev/null +++ b/patches/extension/radiant/PebbleBridge$2.smali @@ -0,0 +1,50 @@ +.class Lradiant/PebbleBridge$2; +.super Ljava/lang/Object; + +# interfaces +.implements Ljava/lang/Runnable; + + +# annotations +.annotation system Ldalvik/annotation/EnclosingMethod; + value = Lradiant/PebbleBridge;->onLyrics(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V +.end annotation + +.annotation system Ldalvik/annotation/InnerClass; + accessFlags = 0x0 + name = null +.end annotation + + +# direct methods +.method constructor ()V + .registers 1 + + invoke-direct {p0}, Ljava/lang/Object;->()V + + return-void +.end method + + +# virtual methods +.method public run()V + .registers 2 + + # getter for: Lradiant/PebbleBridge;->instance:Lradiant/PebbleBridge; + invoke-static {}, Lradiant/PebbleBridge;->access$100()Lradiant/PebbleBridge; + + move-result-object v0 + + if-eqz v0, :cond_d + + # getter for: Lradiant/PebbleBridge;->instance:Lradiant/PebbleBridge; + invoke-static {}, Lradiant/PebbleBridge;->access$100()Lradiant/PebbleBridge; + + move-result-object v0 + + # invokes: Lradiant/PebbleBridge;->sendLyrics()V + invoke-static {v0}, Lradiant/PebbleBridge;->access$200(Lradiant/PebbleBridge;)V + + :cond_d + return-void +.end method diff --git a/patches/extension/radiant/PebbleBridge.smali b/patches/extension/radiant/PebbleBridge.smali new file mode 100644 index 0000000..9371480 --- /dev/null +++ b/patches/extension/radiant/PebbleBridge.smali @@ -0,0 +1,1086 @@ +.class public final Lradiant/PebbleBridge; +.super Ljava/lang/Object; + +# interfaces +.implements Landroid/content/ServiceConnection; + + +# static fields +.field private static final KEEPALIVE_MS:J = 0x1388L + +.field private static final LOCK:Ljava/lang/Object; + +.field private static final MAIN:Landroid/os/Handler; + +.field private static final MANAGER_PACKAGE:Ljava/lang/String; = "com.meowarex.rlmobile" + +.field private static final MSG_LYRICS:I = 0x3 + +.field private static final MSG_POSITION:I = 0x2 + +.field private static final MSG_SESSION:I = 0x1 + +.field private static final RELAY_SERVICE:Ljava/lang/String; = "com.meowarex.rlmobile.pebble.PebbleRelayService" + +.field private static final TAG:Ljava/lang/String; = "RLPebble" + +.field private static final TOLERANCE_MS:J = 0xfL + +.field private static instance:Lradiant/PebbleBridge; + +.field private static lyricsBody:Ljava/lang/String; + +.field private static lyricsFailed:Z + +.field private static lyricsKey:Ljava/lang/String; + +.field private static lyricsTitle:Ljava/lang/String; + +.field private static rawAt:J + +.field private static rawPosition:J + + +# instance fields +.field private bound:Z + +.field private final context:Landroid/content/Context; + +.field private controller:Landroid/media/session/MediaController; + +.field private lastSendAt:J + +.field private mediaId:Ljava/lang/String; + +.field private playing:Z + +.field private relay:Landroid/os/Messenger; + +.field private retryBindAt:J + +.field private sentAt:J + +.field private sentPlaying:Z + +.field private sentPosition:J + +.field private token:Landroid/media/session/MediaSession$Token; + + +# direct methods +.method static constructor ()V + .registers 2 + + new-instance v0, Landroid/os/Handler; + + invoke-static {}, Landroid/os/Looper;->getMainLooper()Landroid/os/Looper; + + move-result-object v1 + + invoke-direct {v0, v1}, Landroid/os/Handler;->(Landroid/os/Looper;)V + + sput-object v0, Lradiant/PebbleBridge;->MAIN:Landroid/os/Handler; + + new-instance v0, Ljava/lang/Object; + + invoke-direct {v0}, Ljava/lang/Object;->()V + + sput-object v0, Lradiant/PebbleBridge;->LOCK:Ljava/lang/Object; + + const-wide/16 v0, -0x1 + + sput-wide v0, Lradiant/PebbleBridge;->rawPosition:J + + return-void +.end method + +.method private constructor (Landroid/content/Context;)V + .registers 4 + + invoke-direct {p0}, Ljava/lang/Object;->()V + + const-wide/16 v0, -0x1 + + iput-wide v0, p0, Lradiant/PebbleBridge;->sentPosition:J + + iput-object p1, p0, Lradiant/PebbleBridge;->context:Landroid/content/Context; + + return-void +.end method + +.method static synthetic access$000(JJ)V + .registers 4 + + invoke-static {p0, p1, p2, p3}, Lradiant/PebbleBridge;->onSample(JJ)V + + return-void +.end method + +.method static synthetic access$100()Lradiant/PebbleBridge; + .registers 1 + + sget-object v0, Lradiant/PebbleBridge;->instance:Lradiant/PebbleBridge; + + return-object v0 +.end method + +.method static synthetic access$200(Lradiant/PebbleBridge;)V + .registers 1 + + invoke-direct {p0}, Lradiant/PebbleBridge;->sendLyrics()V + + return-void +.end method + +.method private consider(JJZ)V + .registers 13 + + iget-boolean v0, p0, Lradiant/PebbleBridge;->sentPlaying:Z + + iget-wide v1, p0, Lradiant/PebbleBridge;->sentPosition:J + + if-eqz v0, :cond_b + + iget-wide v3, p0, Lradiant/PebbleBridge;->sentAt:J + + sub-long v3, p3, v3 + + add-long/2addr v1, v3 + + :cond_b + iget-wide v3, p0, Lradiant/PebbleBridge;->sentPosition:J + + const-wide/16 v5, 0x0 + + cmp-long v0, v3, v5 + + if-ltz v0, :cond_22 + + sub-long v0, p1, v1 + + invoke-static {v0, v1}, Ljava/lang/Math;->abs(J)J + + move-result-wide v0 + + const-wide/16 v2, 0xf + + cmp-long v0, v0, v2 + + if-lez v0, :cond_20 + + goto :goto_22 + + :cond_20 + const/4 v0, 0x0 + + goto :goto_23 + + :cond_22 + :goto_22 + const/4 v0, 0x1 + + :goto_23 + if-nez p5, :cond_38 + + if-nez v0, :cond_38 + + iget-boolean p5, p0, Lradiant/PebbleBridge;->sentPlaying:Z + + iget-boolean v0, p0, Lradiant/PebbleBridge;->playing:Z + + if-ne p5, v0, :cond_38 + + iget-wide v0, p0, Lradiant/PebbleBridge;->lastSendAt:J + + sub-long v0, p3, v0 + + const-wide/16 v2, 0x1388 + + cmp-long p5, v0, v2 + + if-gez p5, :cond_38 + + return-void + + :cond_38 + invoke-direct {p0, p1, p2, p3, p4}, Lradiant/PebbleBridge;->sendPosition(JJ)Z + + move-result p5 + + if-eqz p5, :cond_48 + + iput-wide p1, p0, Lradiant/PebbleBridge;->sentPosition:J + + iput-wide p3, p0, Lradiant/PebbleBridge;->sentAt:J + + iget-boolean p1, p0, Lradiant/PebbleBridge;->playing:Z + + iput-boolean p1, p0, Lradiant/PebbleBridge;->sentPlaying:Z + + iput-wide p3, p0, Lradiant/PebbleBridge;->lastSendAt:J + + :cond_48 + return-void +.end method + +.method public static onDestroy()V + .registers 3 + + :try_start_0 + sget-object v0, Lradiant/PebbleBridge;->instance:Lradiant/PebbleBridge; + + if-eqz v0, :cond_15 + + sget-object v0, Lradiant/PebbleBridge;->instance:Lradiant/PebbleBridge; + + invoke-direct {v0}, Lradiant/PebbleBridge;->release()V + + const/4 v0, 0x0 + + sput-object v0, Lradiant/PebbleBridge;->instance:Lradiant/PebbleBridge; + :try_end_c + .catchall {:try_start_0 .. :try_end_c} :catchall_d + + goto :goto_15 + + :catchall_d + move-exception v0 + + const-string v1, "RLPebble" + + const-string v2, "onDestroy failed" + + invoke-static {v1, v2, v0}, Landroid/util/Log;->w(Ljava/lang/String;Ljava/lang/String;Ljava/lang/Throwable;)I + + :cond_15 + :goto_15 + return-void +.end method + +.method public static onLyrics(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V + .registers 5 + + sget-object v0, Lradiant/PebbleBridge;->LOCK:Ljava/lang/Object; + + monitor-enter v0 + + if-nez p2, :cond_15 + + if-eqz p0, :cond_15 + + :try_start_7 + sget-object v1, Lradiant/PebbleBridge;->lyricsKey:Ljava/lang/String; + + invoke-virtual {p0, v1}, Ljava/lang/String;->equals(Ljava/lang/Object;)Z + + move-result v1 + + if-eqz v1, :cond_15 + + sget-object v1, Lradiant/PebbleBridge;->lyricsBody:Ljava/lang/String; + + if-eqz v1, :cond_15 + + monitor-exit v0 + + return-void + + :cond_15 + sput-object p0, Lradiant/PebbleBridge;->lyricsKey:Ljava/lang/String; + + sput-object p1, Lradiant/PebbleBridge;->lyricsTitle:Ljava/lang/String; + + sput-object p2, Lradiant/PebbleBridge;->lyricsBody:Ljava/lang/String; + + if-nez p2, :cond_1f + + const/4 p0, 0x1 + + goto :goto_20 + + :cond_1f + const/4 p0, 0x0 + + :goto_20 + sput-boolean p0, Lradiant/PebbleBridge;->lyricsFailed:Z + + monitor-exit v0 + :try_end_23 + .catchall {:try_start_7 .. :try_end_23} :catchall_2e + + sget-object p0, Lradiant/PebbleBridge;->MAIN:Landroid/os/Handler; + + new-instance p1, Lradiant/PebbleBridge$2; + + invoke-direct {p1}, Lradiant/PebbleBridge$2;->()V + + invoke-virtual {p0, p1}, Landroid/os/Handler;->post(Ljava/lang/Runnable;)Z + + return-void + + :catchall_2e + move-exception p0 + + :try_start_2f + monitor-exit v0 + :try_end_30 + .catchall {:try_start_2f .. :try_end_30} :catchall_2e + + throw p0 +.end method + +.method public static onPosition(J)V + .registers 6 + + invoke-static {}, Landroid/os/SystemClock;->elapsedRealtime()J + + move-result-wide v0 + + sget-object v2, Lradiant/PebbleBridge;->LOCK:Ljava/lang/Object; + + monitor-enter v2 + + :try_start_7 + sput-wide p0, Lradiant/PebbleBridge;->rawPosition:J + + sput-wide v0, Lradiant/PebbleBridge;->rawAt:J + + monitor-exit v2 + :try_end_c + .catchall {:try_start_7 .. :try_end_c} :catchall_2a + + sget-object v2, Lradiant/PebbleBridge;->instance:Lradiant/PebbleBridge; + + if-nez v2, :cond_11 + + return-void + + :cond_11 + invoke-static {}, Landroid/os/Looper;->myLooper()Landroid/os/Looper; + + move-result-object v2 + + invoke-static {}, Landroid/os/Looper;->getMainLooper()Landroid/os/Looper; + + move-result-object v3 + + if-ne v2, v3, :cond_1f + + invoke-static {p0, p1, v0, v1}, Lradiant/PebbleBridge;->onSample(JJ)V + + goto :goto_29 + + :cond_1f + sget-object v2, Lradiant/PebbleBridge;->MAIN:Landroid/os/Handler; + + new-instance v3, Lradiant/PebbleBridge$1; + + invoke-direct {v3, p0, p1, v0, v1}, Lradiant/PebbleBridge$1;->(JJ)V + + invoke-virtual {v2, v3}, Landroid/os/Handler;->post(Ljava/lang/Runnable;)Z + + :goto_29 + return-void + + :catchall_2a + move-exception p0 + + :try_start_2b + monitor-exit v2 + :try_end_2c + .catchall {:try_start_2b .. :try_end_2c} :catchall_2a + + throw p0 +.end method + +.method private static onSample(JJ)V + .registers 10 + + sget-object v0, Lradiant/PebbleBridge;->instance:Lradiant/PebbleBridge; + + if-eqz v0, :cond_a + + const/4 v5, 0x0 + + move-wide v1, p0 + + move-wide v3, p2 + + invoke-direct/range {v0 .. v5}, Lradiant/PebbleBridge;->consider(JJZ)V + + :cond_a + return-void +.end method + +.method public static onState(Landroid/content/Context;Landroid/support/v4/media/session/MediaSessionCompat;)V + .registers 4 + + :try_start_0 + sget-object v0, Lradiant/PebbleBridge;->instance:Lradiant/PebbleBridge; + + if-nez v0, :cond_12 + + invoke-virtual {p0}, Landroid/content/Context;->getApplicationContext()Landroid/content/Context; + + move-result-object v0 + + new-instance v1, Lradiant/PebbleBridge; + + if-eqz v0, :cond_d + + move-object p0, v0 + + :cond_d + invoke-direct {v1, p0}, Lradiant/PebbleBridge;->(Landroid/content/Context;)V + + sput-object v1, Lradiant/PebbleBridge;->instance:Lradiant/PebbleBridge; + + :cond_12 + sget-object p0, Lradiant/PebbleBridge;->instance:Lradiant/PebbleBridge; + + invoke-direct {p0, p1}, Lradiant/PebbleBridge;->update(Landroid/support/v4/media/session/MediaSessionCompat;)V + :try_end_17 + .catchall {:try_start_0 .. :try_end_17} :catchall_18 + + goto :goto_20 + + :catchall_18 + move-exception p0 + + const-string p1, "RLPebble" + + const-string v0, "onState failed" + + invoke-static {p1, v0, p0}, Landroid/util/Log;->w(Ljava/lang/String;Ljava/lang/String;Ljava/lang/Throwable;)I + + :goto_20 + return-void +.end method + +.method private readPlaying()Z + .registers 3 + + iget-object v0, p0, Lradiant/PebbleBridge;->controller:Landroid/media/session/MediaController; + + if-eqz v0, :cond_b + + iget-object v0, p0, Lradiant/PebbleBridge;->controller:Landroid/media/session/MediaController; + + invoke-virtual {v0}, Landroid/media/session/MediaController;->getPlaybackState()Landroid/media/session/PlaybackState; + + move-result-object v0 + + goto :goto_c + + :cond_b + const/4 v0, 0x0 + + :goto_c + if-eqz v0, :cond_17 + + invoke-virtual {v0}, Landroid/media/session/PlaybackState;->getState()I + + move-result v0 + + const/4 v1, 0x3 + + if-ne v0, v1, :cond_17 + + const/4 v0, 0x1 + + goto :goto_18 + + :cond_17 + const/4 v0, 0x0 + + :goto_18 + return v0 +.end method + +.method private release()V + .registers 2 + + iget-boolean v0, p0, Lradiant/PebbleBridge;->bound:Z + + if-eqz v0, :cond_b + + :try_start_4 + iget-object v0, p0, Lradiant/PebbleBridge;->context:Landroid/content/Context; + + invoke-virtual {v0, p0}, Landroid/content/Context;->unbindService(Landroid/content/ServiceConnection;)V + :try_end_9 + .catchall {:try_start_4 .. :try_end_9} :catchall_a + + goto :goto_b + + :catchall_a + move-exception v0 + + :cond_b + :goto_b + const/4 v0, 0x0 + + iput-boolean v0, p0, Lradiant/PebbleBridge;->bound:Z + + const/4 v0, 0x0 + + iput-object v0, p0, Lradiant/PebbleBridge;->relay:Landroid/os/Messenger; + + return-void +.end method + +.method private send(ILandroid/os/Bundle;)Z + .registers 6 + + iget-object v0, p0, Lradiant/PebbleBridge;->relay:Landroid/os/Messenger; + + const/4 v1, 0x0 + + if-nez v0, :cond_6 + + return v1 + + :cond_6 + const/4 v2, 0x0 + + :try_start_7 + invoke-static {v2, p1}, Landroid/os/Message;->obtain(Landroid/os/Handler;I)Landroid/os/Message; + + move-result-object p1 + + invoke-virtual {p1, p2}, Landroid/os/Message;->setData(Landroid/os/Bundle;)V + + invoke-virtual {v0, p1}, Landroid/os/Messenger;->send(Landroid/os/Message;)V + :try_end_11 + .catchall {:try_start_7 .. :try_end_11} :catchall_13 + + const/4 p1, 0x1 + + return p1 + + :catchall_13 + move-exception p1 + + iput-object v2, p0, Lradiant/PebbleBridge;->relay:Landroid/os/Messenger; + + return v1 +.end method + +.method private sendLatest(Z)V + .registers 14 + + sget-object v1, Lradiant/PebbleBridge;->LOCK:Ljava/lang/Object; + + monitor-enter v1 + + :try_start_3 + sget-wide v2, Lradiant/PebbleBridge;->rawPosition:J + + sget-wide v4, Lradiant/PebbleBridge;->rawAt:J + + monitor-exit v1 + :try_end_8 + .catchall {:try_start_3 .. :try_end_8} :catchall_1f + + const-wide/16 v0, 0x0 + + cmp-long v0, v2, v0 + + if-gez v0, :cond_f + + return-void + + :cond_f + invoke-static {}, Landroid/os/SystemClock;->elapsedRealtime()J + + move-result-wide v9 + + if-eqz p1, :cond_18 + + sub-long v0, v9, v4 + + add-long/2addr v2, v0 + + :cond_18 + move-wide v7, v2 + + const/4 v11, 0x1 + + move-object v6, p0 + + invoke-direct/range {v6 .. v11}, Lradiant/PebbleBridge;->consider(JJZ)V + + return-void + + :catchall_1f + move-exception v0 + + move-object p1, v0 + + :try_start_21 + monitor-exit v1 + :try_end_22 + .catchall {:try_start_21 .. :try_end_22} :catchall_1f + + throw p1 +.end method + +.method private sendLyrics()V + .registers 5 + + iget-object v0, p0, Lradiant/PebbleBridge;->relay:Landroid/os/Messenger; + + if-nez v0, :cond_5 + + return-void + + :cond_5 + new-instance v0, Landroid/os/Bundle; + + invoke-direct {v0}, Landroid/os/Bundle;->()V + + sget-object v1, Lradiant/PebbleBridge;->LOCK:Ljava/lang/Object; + + monitor-enter v1 + + :try_start_d + sget-object v2, Lradiant/PebbleBridge;->lyricsKey:Ljava/lang/String; + + if-nez v2, :cond_13 + + monitor-exit v1 + + return-void + + :cond_13 + const-string v2, "key" + + sget-object v3, Lradiant/PebbleBridge;->lyricsKey:Ljava/lang/String; + + invoke-virtual {v0, v2, v3}, Landroid/os/Bundle;->putString(Ljava/lang/String;Ljava/lang/String;)V + + const-string v2, "title" + + sget-object v3, Lradiant/PebbleBridge;->lyricsTitle:Ljava/lang/String; + + invoke-virtual {v0, v2, v3}, Landroid/os/Bundle;->putString(Ljava/lang/String;Ljava/lang/String;)V + + sget-object v2, Lradiant/PebbleBridge;->lyricsBody:Ljava/lang/String; + + if-eqz v2, :cond_2c + + const-string v2, "body" + + sget-object v3, Lradiant/PebbleBridge;->lyricsBody:Ljava/lang/String; + + invoke-virtual {v0, v2, v3}, Landroid/os/Bundle;->putString(Ljava/lang/String;Ljava/lang/String;)V + + :cond_2c + const-string v2, "failed" + + sget-boolean v3, Lradiant/PebbleBridge;->lyricsFailed:Z + + invoke-virtual {v0, v2, v3}, Landroid/os/Bundle;->putBoolean(Ljava/lang/String;Z)V + + monitor-exit v1 + :try_end_34 + .catchall {:try_start_d .. :try_end_34} :catchall_39 + + const/4 v1, 0x3 + + invoke-direct {p0, v1, v0}, Lradiant/PebbleBridge;->send(ILandroid/os/Bundle;)Z + + return-void + + :catchall_39 + move-exception v0 + + :try_start_3a + monitor-exit v1 + :try_end_3b + .catchall {:try_start_3a .. :try_end_3b} :catchall_39 + + throw v0 +.end method + +.method private sendPosition(JJ)Z + .registers 7 + + iget-object v0, p0, Lradiant/PebbleBridge;->relay:Landroid/os/Messenger; + + if-nez v0, :cond_6 + + const/4 p1, 0x0 + + return p1 + + :cond_6 + new-instance v0, Landroid/os/Bundle; + + invoke-direct {v0}, Landroid/os/Bundle;->()V + + const-string v1, "position" + + invoke-virtual {v0, v1, p1, p2}, Landroid/os/Bundle;->putLong(Ljava/lang/String;J)V + + const-string p1, "elapsedRealtime" + + invoke-virtual {v0, p1, p3, p4}, Landroid/os/Bundle;->putLong(Ljava/lang/String;J)V + + const-string p1, "playing" + + iget-boolean p2, p0, Lradiant/PebbleBridge;->playing:Z + + invoke-virtual {v0, p1, p2}, Landroid/os/Bundle;->putBoolean(Ljava/lang/String;Z)V + + iget-object p1, p0, Lradiant/PebbleBridge;->mediaId:Ljava/lang/String; + + if-eqz p1, :cond_27 + + const-string p1, "mediaId" + + iget-object p2, p0, Lradiant/PebbleBridge;->mediaId:Ljava/lang/String; + + invoke-virtual {v0, p1, p2}, Landroid/os/Bundle;->putString(Ljava/lang/String;Ljava/lang/String;)V + + :cond_27 + const/4 p1, 0x2 + + invoke-direct {p0, p1, v0}, Lradiant/PebbleBridge;->send(ILandroid/os/Bundle;)Z + + move-result p1 + + return p1 +.end method + +.method private sendSession()V + .registers 4 + + iget-object v0, p0, Lradiant/PebbleBridge;->relay:Landroid/os/Messenger; + + if-eqz v0, :cond_24 + + iget-object v0, p0, Lradiant/PebbleBridge;->token:Landroid/media/session/MediaSession$Token; + + if-nez v0, :cond_9 + + goto :goto_24 + + :cond_9 + new-instance v0, Landroid/os/Bundle; + + invoke-direct {v0}, Landroid/os/Bundle;->()V + + const-string v1, "token" + + iget-object v2, p0, Lradiant/PebbleBridge;->token:Landroid/media/session/MediaSession$Token; + + invoke-virtual {v0, v1, v2}, Landroid/os/Bundle;->putParcelable(Ljava/lang/String;Landroid/os/Parcelable;)V + + iget-object v1, p0, Lradiant/PebbleBridge;->context:Landroid/content/Context; + + invoke-virtual {v1}, Landroid/content/Context;->getPackageName()Ljava/lang/String; + + move-result-object v1 + + const-string v2, "package" + + invoke-virtual {v0, v2, v1}, Landroid/os/Bundle;->putString(Ljava/lang/String;Ljava/lang/String;)V + + const/4 v1, 0x1 + + invoke-direct {p0, v1, v0}, Lradiant/PebbleBridge;->send(ILandroid/os/Bundle;)Z + + :cond_24 + :goto_24 + return-void +.end method + +.method private update(Landroid/support/v4/media/session/MediaSessionCompat;)V + .registers 9 + + if-nez p1, :cond_3 + + return-void + + :cond_3 + invoke-virtual {p1}, Landroid/support/v4/media/session/MediaSessionCompat;->getSessionToken()Landroid/support/v4/media/session/MediaSessionCompat$Token; + + move-result-object p1 + + const/4 v0, 0x0 + + if-eqz p1, :cond_f + + invoke-virtual {p1}, Landroid/support/v4/media/session/MediaSessionCompat$Token;->getToken()Ljava/lang/Object; + + move-result-object p1 + + goto :goto_10 + + :cond_f + move-object p1, v0 + + :goto_10 + instance-of v1, p1, Landroid/media/session/MediaSession$Token; + + if-nez v1, :cond_15 + + return-void + + :cond_15 + iget-object v1, p0, Lradiant/PebbleBridge;->token:Landroid/media/session/MediaSession$Token; + + invoke-virtual {p1, v1}, Ljava/lang/Object;->equals(Ljava/lang/Object;)Z + + move-result v1 + + if-nez v1, :cond_2f + + check-cast p1, Landroid/media/session/MediaSession$Token; + + iput-object p1, p0, Lradiant/PebbleBridge;->token:Landroid/media/session/MediaSession$Token; + + new-instance p1, Landroid/media/session/MediaController; + + iget-object v1, p0, Lradiant/PebbleBridge;->context:Landroid/content/Context; + + iget-object v2, p0, Lradiant/PebbleBridge;->token:Landroid/media/session/MediaSession$Token; + + invoke-direct {p1, v1, v2}, Landroid/media/session/MediaController;->(Landroid/content/Context;Landroid/media/session/MediaSession$Token;)V + + iput-object p1, p0, Lradiant/PebbleBridge;->controller:Landroid/media/session/MediaController; + + invoke-direct {p0}, Lradiant/PebbleBridge;->sendSession()V + + :cond_2f + iget-boolean p1, p0, Lradiant/PebbleBridge;->bound:Z + + const/4 v1, 0x0 + + const/4 v2, 0x1 + + if-nez p1, :cond_79 + + invoke-static {}, Landroid/os/SystemClock;->elapsedRealtime()J + + move-result-wide v3 + + iget-wide v5, p0, Lradiant/PebbleBridge;->retryBindAt:J + + cmp-long p1, v3, v5 + + if-ltz p1, :cond_79 + + new-instance p1, Landroid/content/Intent; + + invoke-direct {p1}, Landroid/content/Intent;->()V + + new-instance v3, Landroid/content/ComponentName; + + const-string v4, "com.meowarex.rlmobile" + + const-string v5, "com.meowarex.rlmobile.pebble.PebbleRelayService" + + invoke-direct {v3, v4, v5}, Landroid/content/ComponentName;->(Ljava/lang/String;Ljava/lang/String;)V + + invoke-virtual {p1, v3}, Landroid/content/Intent;->setComponent(Landroid/content/ComponentName;)Landroid/content/Intent; + + move-result-object p1 + + :try_start_51 + iget-object v3, p0, Lradiant/PebbleBridge;->context:Landroid/content/Context; + + invoke-virtual {v3, p1, p0, v2}, Landroid/content/Context;->bindService(Landroid/content/Intent;Landroid/content/ServiceConnection;I)Z + + move-result p1 + + iput-boolean p1, p0, Lradiant/PebbleBridge;->bound:Z + :try_end_59 + .catchall {:try_start_51 .. :try_end_59} :catchall_5a + + goto :goto_5d + + :catchall_5a + move-exception p1 + + iput-boolean v1, p0, Lradiant/PebbleBridge;->bound:Z + + :goto_5d + iget-boolean p1, p0, Lradiant/PebbleBridge;->bound:Z + + if-nez p1, :cond_79 + + invoke-static {}, Landroid/os/SystemClock;->elapsedRealtime()J + + move-result-wide v3 + + const-wide/32 v5, 0xea60 + + add-long/2addr v3, v5 + + iput-wide v3, p0, Lradiant/PebbleBridge;->retryBindAt:J + + :try_start_6b + iget-object p1, p0, Lradiant/PebbleBridge;->context:Landroid/content/Context; + + invoke-virtual {p1, p0}, Landroid/content/Context;->unbindService(Landroid/content/ServiceConnection;)V + :try_end_70 + .catchall {:try_start_6b .. :try_end_70} :catchall_71 + + goto :goto_72 + + :catchall_71 + move-exception p1 + + :goto_72 + const-string p1, "RLPebble" + + const-string v3, "RL Manager relay not available" + + invoke-static {p1, v3}, Landroid/util/Log;->i(Ljava/lang/String;Ljava/lang/String;)I + + :cond_79 + iget-boolean p1, p0, Lradiant/PebbleBridge;->playing:Z + + invoke-direct {p0}, Lradiant/PebbleBridge;->readPlaying()Z + + move-result v3 + + iput-boolean v3, p0, Lradiant/PebbleBridge;->playing:Z + + iget-object v3, p0, Lradiant/PebbleBridge;->controller:Landroid/media/session/MediaController; + + if-eqz v3, :cond_8c + + iget-object v3, p0, Lradiant/PebbleBridge;->controller:Landroid/media/session/MediaController; + + invoke-virtual {v3}, Landroid/media/session/MediaController;->getMetadata()Landroid/media/MediaMetadata; + + move-result-object v3 + + goto :goto_8d + + :cond_8c + move-object v3, v0 + + :goto_8d + if-eqz v3, :cond_95 + + const-string v0, "android.media.metadata.MEDIA_ID" + + invoke-virtual {v3, v0}, Landroid/media/MediaMetadata;->getString(Ljava/lang/String;)Ljava/lang/String; + + move-result-object v0 + + :cond_95 + if-eqz v0, :cond_a0 + + iget-object v3, p0, Lradiant/PebbleBridge;->mediaId:Ljava/lang/String; + + invoke-virtual {v0, v3}, Ljava/lang/String;->equals(Ljava/lang/Object;)Z + + move-result v3 + + if-nez v3, :cond_a0 + + move v1, v2 + + :cond_a0 + iput-object v0, p0, Lradiant/PebbleBridge;->mediaId:Ljava/lang/String; + + if-eqz v1, :cond_a9 + + const-wide/16 v0, -0x1 + + iput-wide v0, p0, Lradiant/PebbleBridge;->sentPosition:J + + goto :goto_b0 + + :cond_a9 + iget-boolean v0, p0, Lradiant/PebbleBridge;->playing:Z + + if-eq p1, v0, :cond_b0 + + invoke-direct {p0, p1}, Lradiant/PebbleBridge;->sendLatest(Z)V + + :cond_b0 + :goto_b0 + return-void +.end method + + +# virtual methods +.method public onBindingDied(Landroid/content/ComponentName;)V + .registers 2 + + const/4 p1, 0x0 + + iput-object p1, p0, Lradiant/PebbleBridge;->relay:Landroid/os/Messenger; + + iget-boolean p1, p0, Lradiant/PebbleBridge;->bound:Z + + if-eqz p1, :cond_11 + + :try_start_7 + iget-object p1, p0, Lradiant/PebbleBridge;->context:Landroid/content/Context; + + invoke-virtual {p1, p0}, Landroid/content/Context;->unbindService(Landroid/content/ServiceConnection;)V + :try_end_c + .catchall {:try_start_7 .. :try_end_c} :catchall_d + + goto :goto_e + + :catchall_d + move-exception p1 + + :goto_e + const/4 p1, 0x0 + + iput-boolean p1, p0, Lradiant/PebbleBridge;->bound:Z + + :cond_11 + return-void +.end method + +.method public onNullBinding(Landroid/content/ComponentName;)V + .registers 2 + + invoke-virtual {p0, p1}, Lradiant/PebbleBridge;->onBindingDied(Landroid/content/ComponentName;)V + + return-void +.end method + +.method public onServiceConnected(Landroid/content/ComponentName;Landroid/os/IBinder;)V + .registers 3 + + new-instance p1, Landroid/os/Messenger; + + invoke-direct {p1, p2}, Landroid/os/Messenger;->(Landroid/os/IBinder;)V + + iput-object p1, p0, Lradiant/PebbleBridge;->relay:Landroid/os/Messenger; + + invoke-direct {p0}, Lradiant/PebbleBridge;->sendSession()V + + const-wide/16 p1, -0x1 + + iput-wide p1, p0, Lradiant/PebbleBridge;->sentPosition:J + + iget-boolean p1, p0, Lradiant/PebbleBridge;->playing:Z + + invoke-direct {p0, p1}, Lradiant/PebbleBridge;->sendLatest(Z)V + + invoke-direct {p0}, Lradiant/PebbleBridge;->sendLyrics()V + + return-void +.end method + +.method public onServiceDisconnected(Landroid/content/ComponentName;)V + .registers 2 + + const/4 p1, 0x0 + + iput-object p1, p0, Lradiant/PebbleBridge;->relay:Landroid/os/Messenger; + + return-void +.end method diff --git a/patches/integration-pebble-lyrics.patch b/patches/integration-pebble-lyrics.patch new file mode 100644 index 0000000..51206aa --- /dev/null +++ b/patches/integration-pebble-lyrics.patch @@ -0,0 +1,30 @@ +--- a/radiant/RLAPILyricsWorker.smali ++++ b/radiant/RLAPILyricsWorker.smali +@@ -794,6 +794,12 @@ + :got_body + move-object v11, v3 + ++ iget-object v4, p0, Lradiant/RLAPILyricsWorker;->key:Ljava/lang/String; ++ ++ iget-object v5, p0, Lradiant/RLAPILyricsWorker;->title:Ljava/lang/String; ++ ++ invoke-static {v4, v5, v3}, Lradiant/PebbleBridge;->onLyrics(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V ++ + invoke-static {v3}, Lradiant/RLAPILyricsWorker;->parseLines(Ljava/lang/String;)Ljava/util/ArrayList; + + move-result-object v3 +@@ -911,6 +917,14 @@ + return-void + + :request_failed ++ iget-object v0, p0, Lradiant/RLAPILyricsWorker;->key:Ljava/lang/String; ++ ++ iget-object v1, p0, Lradiant/RLAPILyricsWorker;->title:Ljava/lang/String; ++ ++ const/4 v2, 0x0 ++ ++ invoke-static {v0, v1, v2}, Lradiant/PebbleBridge;->onLyrics(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V ++ + invoke-direct {p0}, Lradiant/RLAPILyricsWorker;->failCurrent()V + + return-void diff --git a/patches/integration-pebble-playback.patch b/patches/integration-pebble-playback.patch new file mode 100644 index 0000000..800716e --- /dev/null +++ b/patches/integration-pebble-playback.patch @@ -0,0 +1,31 @@ +--- a/com/aspiro/wamp/player/MusicService.smali ++++ b/com/aspiro/wamp/player/MusicService.smali +@@ -1495,6 +1495,8 @@ + .line 36 + invoke-virtual {p1, v0}, Landroid/support/v4/media/session/MediaSessionCompat;->setPlaybackState(Landroid/support/v4/media/session/PlaybackStateCompat;)V + ++ invoke-static {p0, p1}, Lradiant/PebbleBridge;->onState(Landroid/content/Context;Landroid/support/v4/media/session/MediaSessionCompat;)V ++ + .line 37 + .line 38 + .line 39 +@@ -2669,6 +2671,8 @@ + .line 1 + invoke-super {p0}, Landroidx/media/MediaBrowserServiceCompat;->onDestroy()V + ++ invoke-static {}, Lradiant/PebbleBridge;->onDestroy()V ++ + .line 2 + .line 3 + .line 4 +--- a/bk0/e.smali ++++ b/bk0/e.smali +@@ -56,6 +56,8 @@ + .line 6 + move-result-wide v0 + ++ invoke-static {v0, v1}, Lradiant/PebbleBridge;->onPosition(J)V ++ + .line 7 + iget-object v2, p0, Lbk0/e;->a:Lbk0/d; + diff --git a/patches/manifest.json b/patches/manifest.json index 98adc34..5a5de53 100644 --- a/patches/manifest.json +++ b/patches/manifest.json @@ -780,6 +780,30 @@ } ] }, + { + "id": "PebbleIntegration", + "order": 91, + "category": "integration", + "fileNames": [ + "integration-pebble-playback.patch", + "integration-pebble-lyrics.patch" + ], + "extensionFiles": [ + "radiant/PebbleBridge.smali", + "radiant/PebbleBridge$1.smali", + "radiant/PebbleBridge$2.smali" + ], + "title": "Pebble Watch", + "description": "Shows synced lyrics on your Pebble Time 2 when listening, Everything is configured and managed in the Pebble App", + "default": false, + "requires": [ + "LyricsRlApi" + ], + "companionApp": { + "storeId": "02b779dcf9084fecb0375df1", + "storeSource": "https://appstore-api.repebble.com/api" + } + }, { "id": "DebugMenuUnlock", "order": 100, diff --git a/tools/pebble-bridge-build/build.sh b/tools/pebble-bridge-build/build.sh new file mode 100755 index 0000000..f167313 --- /dev/null +++ b/tools/pebble-bridge-build/build.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# Builds PebbleBridge*.smali into patches/extension/radiant +# Stubs only satisfy javac +set -euo pipefail + +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo="$(cd "$here/../.." && pwd)" + +ANDROID_SDK="${ANDROID_SDK:-${ANDROID_HOME:-$HOME/Android/Sdk}}" +ANDROID_JAR="${ANDROID_JAR:-$(ls -d "$ANDROID_SDK"/platforms/android-3*/android.jar | sort -V | tail -1)}" +D8="${D8:-$(ls -d "$ANDROID_SDK"/build-tools/*/d8 | sort -V | tail -1)}" +# baksmali and its deps +SMALI_CP="${SMALI_CP:-$(ls "$HOME"/.cache/rlpb/tools/*.jar | tr '\n' :)}" + +javac="${JAVA_HOME:+$JAVA_HOME/bin/}javac" +java="${JAVA_HOME:+$JAVA_HOME/bin/}java" +out="$here/build" + +rm -rf "$out" +mkdir -p "$out/stubs" "$out/classes" "$out/dex" "$out/smali" + +echo "==> javac stubs" +find "$here/stubs" -name '*.java' > "$out/stubs.list" +"$javac" -nowarn -g:none --release 8 -cp "$ANDROID_JAR" -d "$out/stubs" @"$out/stubs.list" 2>&1 | + grep -v 'bootstrap class path\|source value 8\|target value 8\|deprecat\|^[0-9] warning' || true + +echo "==> javac sources" +find "$here/src" -name '*.java' > "$out/src.list" +"$javac" -nowarn -g:none --release 8 -cp "$ANDROID_JAR:$out/stubs" -d "$out/classes" @"$out/src.list" 2>&1 | + grep -v 'bootstrap class path\|source value 8\|target value 8\|deprecat\|^[0-9] warning' || true + +echo "==> d8" +find "$out/classes" -name '*.class' > "$out/class.list" +bash "$D8" --min-api 24 --no-desugaring --lib "$ANDROID_JAR" --classpath "$out/stubs" \ + --output "$out/dex" @"$out/class.list" + +echo "==> baksmali" +"$java" -cp "$SMALI_CP" com.android.tools.smali.baksmali.Main d "$out/dex/classes.dex" -o "$out/smali" + +echo "==> copy" +dest="$repo/patches/extension/radiant" +rm -f "$dest"/PebbleBridge*.smali +cp "$out"/smali/radiant/PebbleBridge*.smali "$dest/" +echo "==> wrote $dest" diff --git a/tools/pebble-bridge-build/src/radiant/PebbleBridge.java b/tools/pebble-bridge-build/src/radiant/PebbleBridge.java new file mode 100644 index 0000000..ce7c073 --- /dev/null +++ b/tools/pebble-bridge-build/src/radiant/PebbleBridge.java @@ -0,0 +1,299 @@ +package radiant; + +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.ServiceConnection; +import android.media.MediaMetadata; +import android.media.session.MediaController; +import android.media.session.MediaSession; +import android.media.session.PlaybackState; +import android.os.Bundle; +import android.os.Handler; +import android.os.IBinder; +import android.os.Looper; +import android.os.Message; +import android.os.Messenger; +import android.os.SystemClock; +import android.support.v4.media.session.MediaSessionCompat; +import android.util.Log; + +// Sends TIDAL playback and lyrics to the Manager relay +public final class PebbleBridge implements ServiceConnection { + private static final String TAG = "RLPebble"; + private static final String MANAGER_PACKAGE = "com.meowarex.rlmobile"; + private static final String RELAY_SERVICE = "com.meowarex.rlmobile.pebble.PebbleRelayService"; + + // Keep in sync with PebbleRelayService + private static final int MSG_SESSION = 1; + private static final int MSG_POSITION = 2; + private static final int MSG_LYRICS = 3; + + // Resend when drift exceeds this + private static final long TOLERANCE_MS = 15L; + private static final long KEEPALIVE_MS = 5000L; + + private static final Handler MAIN = new Handler(Looper.getMainLooper()); + private static final Object LOCK = new Object(); + + private static PebbleBridge instance; + + // Written by the player thread + private static long rawPosition = -1L; + private static long rawAt; + + // Written by the lyrics worker + private static String lyricsKey; + private static String lyricsTitle; + private static String lyricsBody; + private static boolean lyricsFailed; + + private final Context context; + private MediaSession.Token token; + private MediaController controller; + private Messenger relay; + private boolean bound; + private long retryBindAt; + + private boolean playing; + private String mediaId; + private long sentPosition = -1L; + private long sentAt; + private boolean sentPlaying; + private long lastSendAt; + + private PebbleBridge(Context context) { + this.context = context; + } + + public static void onState(Context service, MediaSessionCompat session) { + try { + if (instance == null) { + Context app = service.getApplicationContext(); + instance = new PebbleBridge(app != null ? app : service); + } + instance.update(session); + } catch (Throwable t) { + Log.w(TAG, "onState failed", t); + } + } + + public static void onDestroy() { + try { + if (instance != null) { + instance.release(); + instance = null; + } + } catch (Throwable t) { + Log.w(TAG, "onDestroy failed", t); + } + } + + // Player thread, after getCurrentPosition + public static void onPosition(long position) { + final long at = SystemClock.elapsedRealtime(); + synchronized (LOCK) { + rawPosition = position; + rawAt = at; + } + if (instance == null) return; + if (Looper.myLooper() == Looper.getMainLooper()) { + onSample(position, at); + } else { + MAIN.post(new Runnable() { + @Override + public void run() { + onSample(position, at); + } + }); + } + } + + // Lyrics worker thread, null body means failed + public static void onLyrics(String key, String title, String body) { + synchronized (LOCK) { + if (body == null && key != null && key.equals(lyricsKey) && lyricsBody != null) { + // Keep a body that had no lines + return; + } + lyricsKey = key; + lyricsTitle = title; + lyricsBody = body; + lyricsFailed = body == null; + } + MAIN.post(new Runnable() { + @Override + public void run() { + if (instance != null) instance.sendLyrics(); + } + }); + } + + private static void onSample(long position, long at) { + PebbleBridge bridge = instance; + if (bridge != null) bridge.consider(position, at, false); + } + + private void update(MediaSessionCompat session) { + if (session == null) return; + MediaSessionCompat.Token compat = session.getSessionToken(); + Object raw = compat != null ? compat.getToken() : null; + if (!(raw instanceof MediaSession.Token)) return; + + if (!raw.equals(token)) { + token = (MediaSession.Token) raw; + controller = new MediaController(context, token); + sendSession(); + } + + if (!bound && SystemClock.elapsedRealtime() >= retryBindAt) { + Intent intent = new Intent().setComponent(new ComponentName(MANAGER_PACKAGE, RELAY_SERVICE)); + try { + bound = context.bindService(intent, this, Context.BIND_AUTO_CREATE); + } catch (Throwable t) { + bound = false; + } + if (!bound) { + // Manager missing, retry in a minute + retryBindAt = SystemClock.elapsedRealtime() + 60000L; + try { + context.unbindService(this); + } catch (Throwable ignored) { + } + Log.i(TAG, "RL Manager relay not available"); + } + } + + boolean wasPlaying = playing; + playing = readPlaying(); + MediaMetadata metadata = controller != null ? controller.getMetadata() : null; + String id = metadata != null ? metadata.getString(MediaMetadata.METADATA_KEY_MEDIA_ID) : null; + boolean trackChanged = id != null && !id.equals(mediaId); + mediaId = id; + + if (trackChanged) { + // Next poll sends the new track + sentPosition = -1L; + } else if (wasPlaying != playing) { + // Don't wait for the next poll + sendLatest(wasPlaying); + } + } + + // Extrapolates the last reading to now + private void sendLatest(boolean advance) { + long position; + long at; + synchronized (LOCK) { + position = rawPosition; + at = rawAt; + } + if (position < 0) return; + long now = SystemClock.elapsedRealtime(); + consider(advance ? position + (now - at) : position, now, true); + } + + private boolean readPlaying() { + PlaybackState state = controller != null ? controller.getPlaybackState() : null; + return state != null && state.getState() == PlaybackState.STATE_PLAYING; + } + + private void consider(long position, long at, boolean force) { + long predicted = sentPlaying ? sentPosition + (at - sentAt) : sentPosition; + boolean drifted = sentPosition < 0 || Math.abs(position - predicted) > TOLERANCE_MS; + if (!force && !drifted && sentPlaying == playing && at - lastSendAt < KEEPALIVE_MS) return; + if (sendPosition(position, at)) { + sentPosition = position; + sentAt = at; + sentPlaying = playing; + lastSendAt = at; + } + } + + private void release() { + if (bound) { + try { + context.unbindService(this); + } catch (Throwable ignored) { + } + } + bound = false; + relay = null; + } + + private void sendSession() { + if (relay == null || token == null) return; + Bundle data = new Bundle(); + data.putParcelable("token", token); + data.putString("package", context.getPackageName()); + send(MSG_SESSION, data); + } + + private boolean sendPosition(long position, long at) { + if (relay == null) return false; + Bundle data = new Bundle(); + data.putLong("position", position); + data.putLong("elapsedRealtime", at); + data.putBoolean("playing", playing); + if (mediaId != null) data.putString("mediaId", mediaId); + return send(MSG_POSITION, data); + } + + private void sendLyrics() { + if (relay == null) return; + Bundle data = new Bundle(); + synchronized (LOCK) { + if (lyricsKey == null) return; + data.putString("key", lyricsKey); + data.putString("title", lyricsTitle); + if (lyricsBody != null) data.putString("body", lyricsBody); + data.putBoolean("failed", lyricsFailed); + } + send(MSG_LYRICS, data); + } + + private boolean send(int what, Bundle data) { + Messenger target = relay; + if (target == null) return false; + try { + Message message = Message.obtain(null, what); + message.setData(data); + target.send(message); + return true; + } catch (Throwable t) { + relay = null; + return false; + } + } + + @Override + public void onServiceConnected(ComponentName name, IBinder service) { + relay = new Messenger(service); + sendSession(); + sentPosition = -1L; + sendLatest(playing); + sendLyrics(); + } + + @Override + public void onServiceDisconnected(ComponentName name) { + relay = null; + } + + @Override + public void onBindingDied(ComponentName name) { + relay = null; + if (bound) { + try { + context.unbindService(this); + } catch (Throwable ignored) { + } + bound = false; + } + } + + @Override + public void onNullBinding(ComponentName name) { + onBindingDied(name); + } +} diff --git a/tools/pebble-bridge-build/stubs/android/support/v4/media/session/MediaSessionCompat.java b/tools/pebble-bridge-build/stubs/android/support/v4/media/session/MediaSessionCompat.java new file mode 100644 index 0000000..e57821a --- /dev/null +++ b/tools/pebble-bridge-build/stubs/android/support/v4/media/session/MediaSessionCompat.java @@ -0,0 +1,9 @@ +package android.support.v4.media.session; + +public class MediaSessionCompat { + public Token getSessionToken() { return null; } + + public static final class Token { + public Object getToken() { return null; } + } +}