Compare commits

...
4 Commits
Author SHA1 Message Date
meoware.exe 2cb7100bae Merge pull request #97 from meowarex/dev
RL Pebble <3
2026-09-17 23:27:37 +10:00
meoware.exe 0bae761bc5 bump: versions 2026-09-17 23:26:57 +10:00
meoware.exe 86a0c69d26 Merge pull request #96 from meowarex/dev
RL Pebble <3
2026-09-17 23:21:00 +10:00
meoware.exe 1ab876ea59 RL Pebble <3 2026-09-17 23:20:29 +10:00
27 changed files with 2688 additions and 9 deletions

No files matched your search

+1 -1
View File
@@ -1 +1 @@
1.1.7 1.1.8
+2 -1
View File
@@ -32,7 +32,7 @@ android {
minSdk = 24 minSdk = 24
targetSdk = 36 targetSdk = 36
versionCode = 68 versionCode = 68
versionName = "1.1.7" versionName = "1.1.8"
vectorDrawables { vectorDrawables {
useSupportLibrary = true useSupportLibrary = true
@@ -190,6 +190,7 @@ dependencies {
implementation(libs.bundles.ktor) implementation(libs.bundles.ktor)
implementation(libs.bundles.shizuku) implementation(libs.bundles.shizuku)
implementation(libs.bundles.voyager) implementation(libs.bundles.voyager)
implementation(libs.pebblekit2.client)
implementation(libs.compose.ui.tooling.preview) implementation(libs.compose.ui.tooling.preview)
debugImplementation(libs.compose.ui.tooling) debugImplementation(libs.compose.ui.tooling)
+16
View File
@@ -43,6 +43,22 @@
android:name=".installers.pm.PMIntentReceiver" android:name=".installers.pm.PMIntentReceiver"
android:exported="false" /> 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 <provider
android:name="androidx.core.content.FileProvider" android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.provider" 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", "id": "DebugMenuUnlock",
"order": 100, "order": 100,
@@ -29,6 +29,9 @@ class PatchManifestStep(private val options: PatchOptions) : Step() {
val wazeDefault = container.getStep<SmaliPatchStep>().specs val wazeDefault = container.getStep<SmaliPatchStep>().specs
.firstOrNull { it.id == "WazeIntegration" }?.defaultEnabled ?: false .firstOrNull { it.id == "WazeIntegration" }?.defaultEnabled ?: false
val enableWazeIntegration = options.patchStates["WazeIntegration"] ?: wazeDefault 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( val patchedManifest = ManifestPatcher.patchManifest(
manifestBytes = manifest, manifestBytes = manifest,
@@ -36,6 +39,7 @@ class PatchManifestStep(private val options: PatchOptions) : Step() {
appName = options.appName, appName = options.appName,
debuggable = options.debuggable, debuggable = options.debuggable,
enableWazeIntegration = enableWazeIntegration, enableWazeIntegration = enableWazeIntegration,
enablePebbleIntegration = enablePebbleIntegration,
) )
container.log("Repacking apk with patched manifest") container.log("Repacking apk with patched manifest")
@@ -21,6 +21,8 @@ object ManifestPatcher {
private const val WAZE_PACKAGE = "com.waze" private const val WAZE_PACKAGE = "com.waze"
private const val WAZE_INIT_RECEIVER = "radiant.WazeInitReceiver" private const val WAZE_INIT_RECEIVER = "radiant.WazeInitReceiver"
private const val WAZE_ACTION_INIT = "com.waze.sdk.audio.ACTION_INIT" 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( fun patchManifest(
manifestBytes: ByteArray, manifestBytes: ByteArray,
@@ -28,6 +30,7 @@ object ManifestPatcher {
appName: String, appName: String,
debuggable: Boolean, debuggable: Boolean,
enableWazeIntegration: Boolean, enableWazeIntegration: Boolean,
enablePebbleIntegration: Boolean,
): ByteArray { ): ByteArray {
// Extract original package name to rewrite every reference to it // Extract original package name to rewrite every reference to it
// (permissions, provider authorities) to the new packageName. // (permissions, provider authorities) to the new packageName.
@@ -90,11 +93,12 @@ object ManifestPatcher {
return when (name) { return when (name) {
"queries" -> object : NodeVisitor(nv) { "queries" -> object : NodeVisitor(nv) {
private var hasWazePackage = false private var hasWazePackage = false
private var hasManagerPackage = false
override fun child(ns: String?, name: String): NodeVisitor { override fun child(ns: String?, name: String): NodeVisitor {
val visitor = super.child(ns, name) val visitor = super.child(ns, name)
return if (enableWazeIntegration && name == "package") { return if ((enableWazeIntegration || enablePebbleIntegration) && name == "package") {
object : NodeVisitor(visitor) { object : NodeVisitor(visitor) {
override fun attr( override fun attr(
ns: String?, ns: String?,
@@ -106,6 +110,9 @@ object ManifestPatcher {
if (name == "name" && value == WAZE_PACKAGE) { if (name == "name" && value == WAZE_PACKAGE) {
hasWazePackage = true hasWazePackage = true
} }
if (name == "name" && value == MANAGER_PACKAGE) {
hasManagerPackage = true
}
super.attr(ns, name, resourceId, type, value) super.attr(ns, name, resourceId, type, value)
} }
} }
@@ -127,6 +134,18 @@ object ManifestPatcher {
end() end()
} }
} }
if (enablePebbleIntegration && !hasManagerPackage) {
super.child(null, "package").apply {
attr(
ANDROID_NAMESPACE,
"name",
android.R.attr.name,
TYPE_STRING,
MANAGER_PACKAGE,
)
end()
}
}
super.end() super.end()
} }
} }
@@ -0,0 +1,483 @@
package com.meowarex.rlmobile.pebble
import android.app.Service
import android.content.Intent
import android.content.pm.PackageManager
import android.media.MediaMetadata
import android.media.session.MediaController
import android.media.session.MediaSession
import android.media.session.PlaybackState
import android.os.Build
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.util.Log
import androidx.core.os.BundleCompat
import com.meowarex.rlmobile.BuildConfig
import io.rebble.pebblekit2.client.DefaultPebbleSender
import io.rebble.pebblekit2.common.model.PebbleDictionary
import io.rebble.pebblekit2.common.model.TransmissionResult
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import java.util.zip.CRC32
import kotlin.math.abs
// Relays patched TIDAL playback and lyrics to the watch
class PebbleRelayService : Service() {
private val main = Handler(Looper.getMainLooper())
private val messenger = Messenger(Handler(Looper.getMainLooper()) { handleBridgeMessage(it); true })
private val scope = MainScope()
private val senderLazy = lazy { DefaultPebbleSender(this) }
private val sender by senderLazy
// Reported by the watchapp
private val prefs by lazy { getSharedPreferences("pebble_relay", MODE_PRIVATE) }
private var autoOpen: Boolean
get() = prefs.getBoolean(PREF_AUTO_OPEN, true)
set(value) = prefs.edit().putBoolean(PREF_AUTO_OPEN, value).apply()
private var tidalPackage: String? = null
private var controller: MediaController? = null
// Track
private var mediaId: String? = null
private var trackId = 0L
private var title = ""
private var artist = ""
private var durationMs = 0L
private var status = WatchProtocol.STATUS_IDLE
private var lines: List<WatchProtocol.Line> = emptyList()
private var openedFor: Long = -1
// May arrive before the metadata
private var lyrics: LyricsPayload? = null
// Playback
private var playing = false
private var sample: PositionSample? = null
// Watch
private var watchReady = true
private var watchConfirmed = false
private var inboxSize = 1024
private val queue = ArrayDeque<Outgoing>()
private var inFlight: Outgoing? = null
// Last clock sent to the watch
private var sentPosition = -1L
private var sentTime = 0L
private var sentPlaying = false
private class PositionSample(val mediaId: String?, val position: Long, val time: Long, val playing: Boolean)
private class LyricsPayload(val key: String, val title: String?, val body: String?, val failed: Boolean)
private class Outgoing(val kind: Int, val trackId: Long, val build: () -> PebbleDictionary) {
var attempts = 0
}
override fun onCreate() {
super.onCreate()
current = this
if (PebbleWatchListenerService.watchappOpen) {
watchConfirmed = true
watchReady = true
}
}
override fun onBind(intent: Intent?): IBinder = messenger.binder
override fun onUnbind(intent: Intent?): Boolean {
// TIDAL stopped
releaseController()
resetTrack()
if (autoOpen) {
closeWatchApp()
} else {
enqueue(Outgoing(WatchProtocol.CMD_STATUS, ANY_TRACK) { WatchProtocol.status(0, WatchProtocol.STATUS_IDLE) })
}
return false
}
override fun onDestroy() {
if (current === this) current = null
releaseController()
main.removeCallbacksAndMessages(null)
scope.cancel()
if (senderLazy.isInitialized()) sender.close()
super.onDestroy()
}
private fun handleBridgeMessage(msg: Message) {
val data = msg.data ?: return
data.classLoader = MediaSession.Token::class.java.classLoader
if (msg.what != MSG_SESSION && controller == null) return
when (msg.what) {
MSG_SESSION -> {
val pkg = data.getString("package") ?: return
if (!isTrustedTidal(pkg, msg)) {
Log.w(BuildConfig.TAG, "Pebble relay: rejected session from $pkg")
return
}
val token = BundleCompat.getParcelable(data, "token", MediaSession.Token::class.java) ?: return
attach(pkg, token)
}
MSG_POSITION -> {
val next = PositionSample(
mediaId = data.getString("mediaId"),
position = data.getLong("position"),
time = data.getLong("elapsedRealtime"),
playing = data.getBoolean("playing"),
)
if (next.mediaId != null && next.mediaId != mediaId) return
sample = next
playing = next.playing
syncWatchClock()
}
MSG_LYRICS -> {
val key = data.getString("key") ?: return
lyrics = LyricsPayload(key, data.getString("title"), data.getString("body"), data.getBoolean("failed"))
applyLyrics()
}
}
}
// Patched TIDAL only
private fun isTrustedTidal(pkg: String, msg: Message): Boolean = try {
val info = packageManager.getApplicationInfo(pkg, PackageManager.GET_META_DATA)
val patched = info.metaData?.containsKey("isRadiantLyrics") == true
val uidOk = Build.VERSION.SDK_INT < Build.VERSION_CODES.Q || msg.sendingUid == -1 || msg.sendingUid == info.uid
patched && uidOk
} catch (_: PackageManager.NameNotFoundException) {
false
}
private fun attach(pkg: String, token: MediaSession.Token) {
if (controller?.sessionToken == token && pkg == tidalPackage) return
releaseController()
tidalPackage = pkg
controller = MediaController(this, token).also {
it.registerCallback(controllerCallback, main)
onMetadata(it.metadata)
onPlaybackState(it.playbackState)
}
}
private fun releaseController() {
controller?.unregisterCallback(controllerCallback)
controller = null
}
private val controllerCallback = object : MediaController.Callback() {
override fun onMetadataChanged(metadata: MediaMetadata?) = onMetadata(metadata)
override fun onPlaybackStateChanged(state: PlaybackState?) = onPlaybackState(state)
override fun onSessionDestroyed() {
releaseController()
}
}
private fun resetTrack() {
main.removeCallbacks(lyricsTimeout)
mediaId = null
trackId = 0
title = ""
artist = ""
durationMs = 0
lines = emptyList()
status = WatchProtocol.STATUS_IDLE
playing = false
sample = null
sentPosition = -1
}
private fun onMetadata(metadata: MediaMetadata?) {
metadata ?: return
val newTitle = metadata.getString(MediaMetadata.METADATA_KEY_TITLE).orEmpty()
val newArtist = metadata.getString(MediaMetadata.METADATA_KEY_ARTIST)
?: metadata.getString(MediaMetadata.METADATA_KEY_ALBUM_ARTIST).orEmpty()
val newDuration = metadata.getLong(MediaMetadata.METADATA_KEY_DURATION)
val id = metadata.getString(MediaMetadata.METADATA_KEY_MEDIA_ID) ?: "$newTitle\u0000$newArtist"
if (newTitle.isBlank()) return
if (id == mediaId) {
if (newDuration > 0 && newDuration != durationMs) {
durationMs = newDuration
sendTrack()
}
return
}
mediaId = id
trackId = CRC32().run { update(id.toByteArray()); value }.takeIf { it != 0L } ?: 1L
title = newTitle
artist = newArtist
durationMs = newDuration
lines = emptyList()
status = WatchProtocol.STATUS_LOADING
sample = null
sentPosition = -1
watchReady = true
queue.removeAll { it.trackId != ANY_TRACK }
sendTrack()
sendState()
if (!applyLyrics()) {
main.removeCallbacks(lyricsTimeout)
main.postDelayed(lyricsTimeout, LYRICS_TIMEOUT_MS)
}
}
// Apply lyrics if they match the track
private fun applyLyrics(): Boolean {
val payload = lyrics ?: return false
if (trackId == 0L) return false
val matches = payload.key == mediaId || (payload.title != null && payload.title.equals(title, ignoreCase = true))
if (!matches) return false
main.removeCallbacks(lyricsTimeout)
lines = payload.body?.let {
runCatching { WatchProtocol.shape(it) }
.onFailure { e -> Log.w(BuildConfig.TAG, "Pebble lyrics parse failed", e) }
.getOrNull()
}.orEmpty()
status = when {
payload.failed -> WatchProtocol.STATUS_ERROR
lines.isEmpty() -> WatchProtocol.STATUS_NO_LYRICS
else -> WatchProtocol.STATUS_READY
}
sendTrack()
sendLines()
sendState()
updateWatchApp()
return true
}
// Open with timed lyrics, close without
private fun updateWatchApp() {
if (!autoOpen || trackId == 0L) return
when (status) {
WatchProtocol.STATUS_READY -> if (openedFor != trackId) {
// Once per track so manual closes stick
openedFor = trackId
if (!watchConfirmed) scope.launch { sender.startAppOnTheWatch(WatchProtocol.APP_UUID) }
}
WatchProtocol.STATUS_NO_LYRICS, WatchProtocol.STATUS_ERROR -> closeWatchApp()
}
}
private fun closeWatchApp() {
if (!watchConfirmed) return
scope.launch { sender.stopAppOnTheWatch(WatchProtocol.APP_UUID) }
watchConfirmed = false
watchReady = false
queue.clear()
}
private val lyricsTimeout = Runnable {
if (status == WatchProtocol.STATUS_LOADING && trackId != 0L) {
// Lyrics worker never reported
status = WatchProtocol.STATUS_ERROR
sendTrack()
updateWatchApp()
}
}
private fun onPlaybackState(state: PlaybackState?) {
state ?: return
val nowPlaying = state.state == PlaybackState.STATE_PLAYING
if (nowPlaying == playing) return
playing = nowPlaying
syncWatchClock()
}
// Latest position and phone time, not extrapolated
private fun reference(): Pair<Long, Long> {
val now = SystemClock.elapsedRealtime()
val s = sample
if (s != null && (s.mediaId == null || s.mediaId == mediaId) && (!playing || now - s.time < SAMPLE_STALE_MS)) {
return s.position to s.time
}
// Casting has no player readings
val state = controller?.playbackState ?: return 0L to now
return state.position.coerceAtLeast(0) to (state.lastPositionUpdateTime.takeIf { it > 0 } ?: now)
}
// Resend state only on drift
private fun syncWatchClock() {
if (trackId == 0L) return
val (position, time) = reference()
val believed = if (sentPlaying) sentPosition + (time - sentTime) else sentPosition
if (sentPosition < 0 || sentPlaying != playing || abs(believed - position) > DRIFT_TOLERANCE_MS) {
sendState()
}
}
private fun sendTrack() {
if (trackId == 0L) return
val id = trackId
queue.removeAll { it.kind == WatchProtocol.CMD_TRACK }
enqueue(Outgoing(WatchProtocol.CMD_TRACK, id) {
WatchProtocol.track(
trackId = id,
title = title,
artist = artist,
durationMs = durationMs,
status = status,
lineCount = lines.size,
)
})
}
private fun sendLines() {
if (lines.isEmpty()) return
val id = trackId
queue.removeAll { it.kind == WatchProtocol.CMD_LINES }
val budget = (inboxSize - 64).coerceIn(256, 2000)
for ((first, blob) in WatchProtocol.chunks(lines, budget)) {
enqueue(Outgoing(WatchProtocol.CMD_LINES, id) { WatchProtocol.lines(id, first, blob) })
}
}
private fun sendState() {
if (trackId == 0L) return
queue.removeAll { it.kind == WatchProtocol.CMD_STATE }
val id = trackId
// Built at send time for freshness
enqueue(Outgoing(WatchProtocol.CMD_STATE, id) {
val (position, time) = reference()
sentPosition = position
sentTime = time
sentPlaying = playing
WatchProtocol.state(id, playing, position, time)
})
}
private fun enqueue(item: Outgoing, first: Boolean = false) {
if (first) queue.addFirst(item) else queue.addLast(item)
pump()
}
private fun pump() {
if (inFlight != null) return
if (!watchReady) {
queue.clear()
return
}
// Drop stale tracks
while (queue.firstOrNull()?.let { it.trackId != ANY_TRACK && it.trackId != trackId } == true) {
queue.removeFirst()
}
val item = queue.removeFirstOrNull() ?: return
inFlight = item
item.attempts++
scope.launch {
val results = runCatching { sender.sendDataToPebble(WatchProtocol.APP_UUID, item.build()) }
.onFailure { Log.w(BuildConfig.TAG, "Pebble send failed", it) }
.getOrNull()
onSent(item, results?.values.orEmpty())
}
}
private fun onSent(item: Outgoing, results: Collection<TransmissionResult>) {
inFlight = null
when {
results.any { it == TransmissionResult.Success } -> {
watchConfirmed = true
pump()
}
results.any { it == TransmissionResult.FailedWatchNacked || it == TransmissionResult.FailedTimeout } -> {
if (item.attempts < 3) {
queue.addFirst(item)
} else if (item.kind == WatchProtocol.CMD_STATE) {
sentPosition = -1
}
main.postDelayed({ pump() }, 150)
}
else -> {
// Watchapp not open, wait for it
if (item.kind == WatchProtocol.CMD_STATE) sentPosition = -1
watchReady = false
watchConfirmed = false
queue.clear()
}
}
}
fun onWatchappOpened() {
watchConfirmed = true
watchReady = true
}
fun onWatchappClosed() {
watchConfirmed = false
watchReady = false
queue.clear()
}
fun onWatchMessage(values: Map<Int, Long>, receivedAt: Long) {
watchConfirmed = true
watchReady = true
when (values[WatchProtocol.KEY_CMD]?.toInt()) {
WatchProtocol.CMD_SYNC_REQ -> {
val watchTime = values[WatchProtocol.KEY_WATCH_TIME] ?: return
// Front of queue, stamped at send
enqueue(Outgoing(WatchProtocol.CMD_SYNC, ANY_TRACK) {
WatchProtocol.sync(watchTime, receivedAt, SystemClock.elapsedRealtime())
}, first = true)
}
WatchProtocol.CMD_SETTINGS -> {
values[WatchProtocol.KEY_SET_AUTO_OPEN]?.let { autoOpen = it != 0L }
}
WatchProtocol.CMD_HELLO -> {
values[WatchProtocol.KEY_SET_AUTO_OPEN]?.let { autoOpen = it != 0L }
values[WatchProtocol.KEY_INBOX_SIZE]?.let { inboxSize = it.toInt().coerceIn(256, 8192) }
queue.removeAll { it.kind != WatchProtocol.CMD_SYNC }
sentPosition = -1
if (trackId != 0L) {
sendTrack()
sendLines()
sendState()
} else {
enqueue(Outgoing(WatchProtocol.CMD_STATUS, ANY_TRACK) {
WatchProtocol.status(0, WatchProtocol.STATUS_IDLE)
})
}
}
WatchProtocol.CMD_PLAY_PAUSE -> controller?.transportControls?.let {
if (playing) it.pause() else it.play()
}
WatchProtocol.CMD_NEXT -> controller?.transportControls?.skipToNext()
WatchProtocol.CMD_PREV -> controller?.transportControls?.skipToPrevious()
}
}
companion object {
// Keep in sync with radiant.PebbleBridge
const val MSG_SESSION = 1
const val MSG_POSITION = 2
const val MSG_LYRICS = 3
// Used by PebbleWatchListenerService
@Volatile
var current: PebbleRelayService? = null
private set
private const val ANY_TRACK = -1L
private const val PREF_AUTO_OPEN = "auto_open"
private const val DRIFT_TOLERANCE_MS = 10L
private const val SAMPLE_STALE_MS = 8_000L
private const val LYRICS_TIMEOUT_MS = 90_000L
}
}
@@ -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.alpha
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate import androidx.compose.ui.draw.rotate
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.fromHtml import androidx.compose.ui.text.fromHtml
import androidx.compose.ui.unit.dp 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.R
import com.meowarex.rlmobile.pebble.PebbleWatchapp
import com.meowarex.rlmobile.ui.screens.patchopts.OptionSpec import com.meowarex.rlmobile.ui.screens.patchopts.OptionSpec
import com.meowarex.rlmobile.ui.screens.patchopts.PatchLock import com.meowarex.rlmobile.ui.screens.patchopts.PatchLock
import com.meowarex.rlmobile.ui.screens.patchopts.PatchOptionState import com.meowarex.rlmobile.ui.screens.patchopts.PatchOptionState
@@ -135,7 +140,9 @@ fun PatchSelectionAccordion(
val inlineOptions = patch.advancedOptions.filter { it.isInline } val inlineOptions = patch.advancedOptions.filter { it.isInline }
val hasSheetOptions = patch.advancedOptions.any { !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 // When a patch is enabled and has settings show a carded view of the settings
val carded = checked && hasSettings 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 advancedOptions: List<OptionSpec> = emptyList(),
val category: String = CATEGORY_PATCH, val category: String = CATEGORY_PATCH,
val pathLocked: Boolean = false, val pathLocked: Boolean = false,
/** Watchapp shown under an integration. */
val companionApp: CompanionAppSpec? = null,
) { ) {
val isIntegration: Boolean get() = category == CATEGORY_INTEGRATION 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 @Immutable
@Serializable @Serializable
data class VariantSpec( data class VariantSpec(
@@ -19,13 +19,18 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate import androidx.compose.ui.draw.rotate
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.fromHtml import androidx.compose.ui.text.fromHtml
import androidx.compose.ui.unit.dp 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.R
import com.meowarex.rlmobile.pebble.PebbleWatchapp
import com.meowarex.rlmobile.ui.components.radiant.RadiantButton import com.meowarex.rlmobile.ui.components.radiant.RadiantButton
import com.meowarex.rlmobile.ui.components.radiant.RadiantButtonSize import com.meowarex.rlmobile.ui.components.radiant.RadiantButtonSize
import com.meowarex.rlmobile.ui.components.radiant.RadiantSwitch import com.meowarex.rlmobile.ui.components.radiant.RadiantSwitch
@@ -138,7 +143,9 @@ fun PatchSelectionAccordion(
val inlineOptions = patch.advancedOptions.filter { it.isInline } val inlineOptions = patch.advancedOptions.filter { it.isInline }
val hasSheetOptions = patch.advancedOptions.any { !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 // When a patch is enabled and has settings show a carded view of the settings
val carded = checked && hasSettings 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_installation">Installation</string>
<string name="settings_header_appearance">Appearance</string> <string name="settings_header_appearance">Appearance</string>
<string name="settings_header_info">Info</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_theme">Theme</string>
<string name="setting_ui_style">UI style</string> <string name="setting_ui_style">UI style</string>
<string name="setting_ui_style_desc">Want the old UI back?</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" ktor = "3.4.0"
libsu = "6.0.0" libsu = "6.0.0"
microg = "0.3.6.244735" microg = "0.3.6.244735"
pebblekit2 = "1.3.1"
shimmer = "1.3.3" shimmer = "1.3.3"
shizuku = "13.1.5" shizuku = "13.1.5"
smali = "3.0.9" 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" } microg = { module = "org.microg.gms:play-services-safetynet", version.ref = "microg" }
desugaring = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugaring" } desugaring = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugaring" }
libsu = { module = "com.aliucord.libsu:core", version.ref = "libsu" } libsu = { module = "com.aliucord.libsu:core", version.ref = "libsu" }
pebblekit2-client = { module = "io.rebble.pebblekit2:client", version.ref = "pebblekit2" }
[bundles] [bundles]
accompanist = [ accompanist = [
+1
View File
@@ -21,6 +21,7 @@ Every Patch & Integration can be disabled/enabled & adjusted exactly how you wan
### Integrations ### Integrations
- Waze | Control TIDAL playback from Waze - Waze | Control TIDAL playback from Waze
- Pebble Watch | Synced lyrics (Line, Word or Syllable) & playback controls on your Pebble
### Disclaimer ### 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 The Java side under `tools/kawarp-build/{src,stubs}` is not regenerated and keeps its
markers. 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 | | symbol | uses | referenced from |
|---|---:|---| |---|---:|---|
@@ -298,6 +306,8 @@ markers.
| `Lxf/d$a;` | 2 | `extension/radiant/gestures/queue/SearchPlaylistsResolver.smali` | | `Lxf/d$a;` | 2 | `extension/radiant/gestures/queue/SearchPlaylistsResolver.smali` |
| `La30/b;` | 1 | `player-favorite-heart.patch` | | `La30/b;` | 1 | `player-favorite-heart.patch` |
| `Landroidx/compose/foundation/text/input/a;` | 1 | `player-backdrop-playback.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/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/l;` | 1 | `gestures/queue/swipe-to-queue.patch` |
| `Lcom/aspiro/wamp/nowplaying/view/playqueue/touchmanagement/a;` | 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 | | `Lz70/a;` | `c` | `:Lcom/aspiro/wamp/model/AvailabilityInteractor;` | 1 |
| `Lzf/c;` | `c` | `:Lcom/tidal/android/navigation/NavigationInfo;` | 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. `--- a/` headers naming an obfuscated class. If these move the hunk cannot even be located.
| target | hooked by | | target | hooked by |
|---|---| |---|---|
| `bk0/e.smali` | `integration-pebble-playback.patch` |
| `cg0/h.smali` | `lyrics-rl-api-isrc.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/myalbums/MyAlbumsView.smali` | `gestures/queue/swipe-to-queue.patch` |
| `com/aspiro/wamp/mycollection/subpages/albums/search/SearchAlbumsView.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` | | `p7/a.smali` | `gestures/queue/swipe-to-queue.patch` |
| `v6/e.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 | | 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` | 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` | 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` | | `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` | 9 | added | `Lkotlin/jvm/internal/n;` |
| `integration-waze-playback.patch` | 17 | 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;` | | `integration-waze-playback.patch` | 35 | added | `Lkotlin/jvm/internal/n;` |
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
"tidalVersionCode": 10004, "tidalVersionCode": 10004,
"tidalApkUrl": "https://github.com/meowarex/rl-mobile/releases/download/latest/tidal-stock.apk", "tidalApkUrl": "https://github.com/meowarex/rl-mobile/releases/download/latest/tidal-stock.apk",
"patchesVersion": "1.2.7" "patchesVersion": "1.2.8"
} }
@@ -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", "id": "DebugMenuUnlock",
"order": 100, "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; }
}
}