mirror of
https://github.com/meowarex/rl-mobile.git
synced 2026-10-15 07:53:12 +11:00
RL Pebble <3
This commit is contained in:
1 parent
aaf2d9e99d
commit
1ab876ea59
25 files changed
+2202
-6
No files matched your search
@@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
+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;` |
|
||||||
|
|||||||
@@ -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