RL Pebble <3

This commit is contained in:
meoware.exe committed 2026-09-17 23:20:29 +10:00
1 parent aaf2d9e99d
commit 1ab876ea59
25 files changed
+2202 -6

No files matched your search

+1
View File
@@ -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)
+16
View File
@@ -43,6 +43,22 @@
android:name=".installers.pm.PMIntentReceiver"
android:exported="false" />
<!-- Pebble relay, bound by patched TIDAL -->
<service
android:name=".pebble.PebbleRelayService"
android:exported="true"
tools:ignore="ExportedService" />
<!-- Pebble watch messages -->
<service
android:name=".pebble.PebbleWatchListenerService"
android:exported="true"
tools:ignore="ExportedService">
<intent-filter>
<action android:name="io.rebble.pebblekit2.RECEIVE_DATA_FROM_WATCH" />
</intent-filter>
</service>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.provider"
@@ -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,
@@ -29,6 +29,9 @@ class PatchManifestStep(private val options: PatchOptions) : Step() {
val wazeDefault = container.getStep<SmaliPatchStep>().specs
.firstOrNull { it.id == "WazeIntegration" }?.defaultEnabled ?: false
val enableWazeIntegration = options.patchStates["WazeIntegration"] ?: wazeDefault
val pebbleDefault = container.getStep<SmaliPatchStep>().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")
@@ -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()
}
}
@@ -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
}
}
@@ -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
}
}
@@ -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<Segment>, val right: Boolean)
// Syllable level lines, lit until the next lead line.
fun shape(body: String): List<Line> {
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<Segment>, val right: Boolean)
val raws = ArrayList<Raw>(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<Segment>()
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<String, Int>()
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<Line>, budget: Int): List<Pair<Int, ByteArray>> {
val result = ArrayList<Pair<Int, ByteArray>>()
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<Int, Long> = 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())
}
}
@@ -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) })
}
}
}
}
}
}
@@ -34,6 +34,8 @@ data class PatchSpec(
val advancedOptions: List<OptionSpec> = 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(
@@ -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),
)
}
}
}
}
}
@@ -85,6 +85,10 @@
<string name="settings_header_installation">Installation</string>
<string name="settings_header_appearance">Appearance</string>
<string name="settings_header_info">Info</string>
<string name="patchopts_companion_install">Install watchapp</string>
<string name="patchopts_companion_installed">Installed</string>
<string name="patchopts_companion_customize">Customize</string>
<string name="pebble_app_missing">Couldn\'t open the Pebble app. Is it installed?</string>
<string name="setting_theme">Theme</string>
<string name="setting_ui_style">UI style</string>
<string name="setting_ui_style_desc">Want the old UI back?</string>
+2
View File
@@ -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 = [
+1
View File
@@ -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
+15 -3
View File
@@ -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;` |
@@ -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 <init>(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;-><init>()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
@@ -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 <init>()V
.registers 1
invoke-direct {p0}, Ljava/lang/Object;-><init>()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
File diff suppressed because it is too large. Load diff
+30
View File
@@ -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
+31
View File
@@ -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;
+24
View File
@@ -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,
+44
View File
@@ -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"
@@ -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);
}
}
@@ -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; }
}
}