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; }
+ }
+}