mirror of
https://github.com/meowarex/rl-mobile.git
synced 2026-10-15 07:53:12 +11:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2cb7100bae | ||
|
|
0bae761bc5 | ||
|
|
86a0c69d26 | ||
|
|
1ab876ea59 |
No files matched your search
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
+4
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
+40
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
+58
-1
@@ -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(
|
||||||
|
|||||||
+49
-1
@@ -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>
|
||||||
|
|||||||
@@ -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 = [
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
@@ -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;
|
||||||
|
|
||||||
@@ -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,
|
||||||
|
|||||||
Executable
+44
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+9
@@ -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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user