mirror of
https://github.com/meowarex/rl-mobile.git
synced 2026-10-14 23:43: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.shizuku)
|
||||
implementation(libs.bundles.voyager)
|
||||
implementation(libs.pebblekit2.client)
|
||||
|
||||
implementation(libs.compose.ui.tooling.preview)
|
||||
debugImplementation(libs.compose.ui.tooling)
|
||||
|
||||
@@ -43,6 +43,22 @@
|
||||
android:name=".installers.pm.PMIntentReceiver"
|
||||
android:exported="false" />
|
||||
|
||||
<!-- Pebble relay, bound by patched TIDAL -->
|
||||
<service
|
||||
android:name=".pebble.PebbleRelayService"
|
||||
android:exported="true"
|
||||
tools:ignore="ExportedService" />
|
||||
|
||||
<!-- Pebble watch messages -->
|
||||
<service
|
||||
android:name=".pebble.PebbleWatchListenerService"
|
||||
android:exported="true"
|
||||
tools:ignore="ExportedService">
|
||||
<intent-filter>
|
||||
<action android:name="io.rebble.pebblekit2.RECEIVE_DATA_FROM_WATCH" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.provider"
|
||||
|
||||
@@ -780,6 +780,30 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "PebbleIntegration",
|
||||
"order": 91,
|
||||
"category": "integration",
|
||||
"fileNames": [
|
||||
"integration-pebble-playback.patch",
|
||||
"integration-pebble-lyrics.patch"
|
||||
],
|
||||
"extensionFiles": [
|
||||
"radiant/PebbleBridge.smali",
|
||||
"radiant/PebbleBridge$1.smali",
|
||||
"radiant/PebbleBridge$2.smali"
|
||||
],
|
||||
"title": "Pebble Watch",
|
||||
"description": "Shows synced lyrics on your Pebble Time 2 when listening, Everything is configured and managed in the Pebble App",
|
||||
"default": false,
|
||||
"requires": [
|
||||
"LyricsRlApi"
|
||||
],
|
||||
"companionApp": {
|
||||
"storeId": "02b779dcf9084fecb0375df1",
|
||||
"storeSource": "https://appstore-api.repebble.com/api"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "DebugMenuUnlock",
|
||||
"order": 100,
|
||||
|
||||
+4
@@ -29,6 +29,9 @@ class PatchManifestStep(private val options: PatchOptions) : Step() {
|
||||
val wazeDefault = container.getStep<SmaliPatchStep>().specs
|
||||
.firstOrNull { it.id == "WazeIntegration" }?.defaultEnabled ?: false
|
||||
val enableWazeIntegration = options.patchStates["WazeIntegration"] ?: wazeDefault
|
||||
val pebbleDefault = container.getStep<SmaliPatchStep>().specs
|
||||
.firstOrNull { it.id == "PebbleIntegration" }?.defaultEnabled ?: false
|
||||
val enablePebbleIntegration = options.patchStates["PebbleIntegration"] ?: pebbleDefault
|
||||
|
||||
val patchedManifest = ManifestPatcher.patchManifest(
|
||||
manifestBytes = manifest,
|
||||
@@ -36,6 +39,7 @@ class PatchManifestStep(private val options: PatchOptions) : Step() {
|
||||
appName = options.appName,
|
||||
debuggable = options.debuggable,
|
||||
enableWazeIntegration = enableWazeIntegration,
|
||||
enablePebbleIntegration = enablePebbleIntegration,
|
||||
)
|
||||
|
||||
container.log("Repacking apk with patched manifest")
|
||||
|
||||
@@ -21,6 +21,8 @@ object ManifestPatcher {
|
||||
private const val WAZE_PACKAGE = "com.waze"
|
||||
private const val WAZE_INIT_RECEIVER = "radiant.WazeInitReceiver"
|
||||
private const val WAZE_ACTION_INIT = "com.waze.sdk.audio.ACTION_INIT"
|
||||
// Pebble bridge binds to the Manager
|
||||
private const val MANAGER_PACKAGE = "com.meowarex.rlmobile"
|
||||
|
||||
fun patchManifest(
|
||||
manifestBytes: ByteArray,
|
||||
@@ -28,6 +30,7 @@ object ManifestPatcher {
|
||||
appName: String,
|
||||
debuggable: Boolean,
|
||||
enableWazeIntegration: Boolean,
|
||||
enablePebbleIntegration: Boolean,
|
||||
): ByteArray {
|
||||
// Extract original package name to rewrite every reference to it
|
||||
// (permissions, provider authorities) to the new packageName.
|
||||
@@ -90,11 +93,12 @@ object ManifestPatcher {
|
||||
return when (name) {
|
||||
"queries" -> object : NodeVisitor(nv) {
|
||||
private var hasWazePackage = false
|
||||
private var hasManagerPackage = false
|
||||
|
||||
override fun child(ns: String?, name: String): NodeVisitor {
|
||||
val visitor = super.child(ns, name)
|
||||
|
||||
return if (enableWazeIntegration && name == "package") {
|
||||
return if ((enableWazeIntegration || enablePebbleIntegration) && name == "package") {
|
||||
object : NodeVisitor(visitor) {
|
||||
override fun attr(
|
||||
ns: String?,
|
||||
@@ -106,6 +110,9 @@ object ManifestPatcher {
|
||||
if (name == "name" && value == WAZE_PACKAGE) {
|
||||
hasWazePackage = true
|
||||
}
|
||||
if (name == "name" && value == MANAGER_PACKAGE) {
|
||||
hasManagerPackage = true
|
||||
}
|
||||
super.attr(ns, name, resourceId, type, value)
|
||||
}
|
||||
}
|
||||
@@ -127,6 +134,18 @@ object ManifestPatcher {
|
||||
end()
|
||||
}
|
||||
}
|
||||
if (enablePebbleIntegration && !hasManagerPackage) {
|
||||
super.child(null, "package").apply {
|
||||
attr(
|
||||
ANDROID_NAMESPACE,
|
||||
"name",
|
||||
android.R.attr.name,
|
||||
TYPE_STRING,
|
||||
MANAGER_PACKAGE,
|
||||
)
|
||||
end()
|
||||
}
|
||||
}
|
||||
super.end()
|
||||
}
|
||||
}
|
||||
|
||||
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.clip
|
||||
import androidx.compose.ui.draw.rotate
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.fromHtml
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import com.meowarex.rlmobile.R
|
||||
import com.meowarex.rlmobile.pebble.PebbleWatchapp
|
||||
import com.meowarex.rlmobile.ui.screens.patchopts.OptionSpec
|
||||
import com.meowarex.rlmobile.ui.screens.patchopts.PatchLock
|
||||
import com.meowarex.rlmobile.ui.screens.patchopts.PatchOptionState
|
||||
@@ -135,7 +140,9 @@ fun PatchSelectionAccordion(
|
||||
|
||||
val inlineOptions = patch.advancedOptions.filter { it.isInline }
|
||||
val hasSheetOptions = patch.advancedOptions.any { !it.isInline }
|
||||
val hasSettings = patch.variants.isNotEmpty() || inlineOptions.isNotEmpty() || hasSheetOptions
|
||||
val companionApp = patch.companionApp
|
||||
val hasSettings = patch.variants.isNotEmpty() || inlineOptions.isNotEmpty() || hasSheetOptions ||
|
||||
companionApp != null
|
||||
|
||||
// When a patch is enabled and has settings show a carded view of the settings
|
||||
val carded = checked && hasSettings
|
||||
@@ -240,6 +247,56 @@ fun PatchSelectionAccordion(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (companionApp != null) {
|
||||
val context = LocalContext.current
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
var installState by remember { mutableStateOf(PebbleWatchapp.InstallState.Unknown) }
|
||||
// Recheck on resume
|
||||
LaunchedEffect(lifecycleOwner) {
|
||||
lifecycleOwner.repeatOnLifecycle(Lifecycle.State.RESUMED) {
|
||||
installState = PebbleWatchapp.installState(context)
|
||||
}
|
||||
}
|
||||
val installed = installState == PebbleWatchapp.InstallState.Installed
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
FilledTonalButton(
|
||||
onClick = { PebbleWatchapp.install(context, companionApp) },
|
||||
enabled = !installed && PebbleWatchapp.canInstall(companionApp),
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(if (installed) R.drawable.ic_check_circle else R.drawable.ic_download),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
if (installed) {
|
||||
stringResource(R.string.patchopts_companion_installed)
|
||||
} else {
|
||||
companionApp.installTitle.ifBlank { stringResource(R.string.patchopts_companion_install) }
|
||||
}
|
||||
)
|
||||
}
|
||||
FilledTonalButton(
|
||||
onClick = { PebbleWatchapp.openSettings(context) },
|
||||
enabled = installed,
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_tune),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(companionApp.customizeTitle.ifBlank { stringResource(R.string.patchopts_companion_customize) })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,8 @@ data class PatchSpec(
|
||||
val advancedOptions: List<OptionSpec> = emptyList(),
|
||||
val category: String = CATEGORY_PATCH,
|
||||
val pathLocked: Boolean = false,
|
||||
/** Watchapp shown under an integration. */
|
||||
val companionApp: CompanionAppSpec? = null,
|
||||
) {
|
||||
val isIntegration: Boolean get() = category == CATEGORY_INTEGRATION
|
||||
|
||||
@@ -43,6 +45,17 @@ data class PatchSpec(
|
||||
}
|
||||
}
|
||||
|
||||
@Immutable
|
||||
@Serializable
|
||||
data class CompanionAppSpec(
|
||||
val installTitle: String = "",
|
||||
val customizeTitle: String = "",
|
||||
/** Pebble appstore id. */
|
||||
val storeId: String? = null,
|
||||
/** Appstore feed url. */
|
||||
val storeSource: String? = null,
|
||||
)
|
||||
|
||||
@Immutable
|
||||
@Serializable
|
||||
data class VariantSpec(
|
||||
|
||||
+49
-1
@@ -19,13 +19,18 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.rotate
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.fromHtml
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import com.meowarex.rlmobile.R
|
||||
import com.meowarex.rlmobile.pebble.PebbleWatchapp
|
||||
import com.meowarex.rlmobile.ui.components.radiant.RadiantButton
|
||||
import com.meowarex.rlmobile.ui.components.radiant.RadiantButtonSize
|
||||
import com.meowarex.rlmobile.ui.components.radiant.RadiantSwitch
|
||||
@@ -138,7 +143,9 @@ fun PatchSelectionAccordion(
|
||||
|
||||
val inlineOptions = patch.advancedOptions.filter { it.isInline }
|
||||
val hasSheetOptions = patch.advancedOptions.any { !it.isInline }
|
||||
val hasSettings = patch.variants.isNotEmpty() || inlineOptions.isNotEmpty() || hasSheetOptions
|
||||
val companionApp = patch.companionApp
|
||||
val hasSettings = patch.variants.isNotEmpty() || inlineOptions.isNotEmpty() || hasSheetOptions ||
|
||||
companionApp != null
|
||||
|
||||
// When a patch is enabled and has settings show a carded view of the settings
|
||||
val carded = checked && hasSettings
|
||||
@@ -238,6 +245,47 @@ fun PatchSelectionAccordion(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (companionApp != null) {
|
||||
val context = LocalContext.current
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
var installState by remember { mutableStateOf(PebbleWatchapp.InstallState.Unknown) }
|
||||
// Recheck on resume
|
||||
LaunchedEffect(lifecycleOwner) {
|
||||
lifecycleOwner.repeatOnLifecycle(Lifecycle.State.RESUMED) {
|
||||
installState = PebbleWatchapp.installState(context)
|
||||
}
|
||||
}
|
||||
val installed = installState == PebbleWatchapp.InstallState.Installed
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
RadiantButton(
|
||||
text = if (installed) {
|
||||
stringResource(R.string.patchopts_companion_installed)
|
||||
} else {
|
||||
companionApp.installTitle.ifBlank { stringResource(R.string.patchopts_companion_install) }
|
||||
},
|
||||
icon = painterResource(if (installed) R.drawable.ic_check_circle else R.drawable.ic_download),
|
||||
onClick = { PebbleWatchapp.install(context, companionApp) },
|
||||
enabled = !installed && PebbleWatchapp.canInstall(companionApp),
|
||||
size = RadiantButtonSize.Small,
|
||||
fillWidth = true,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
RadiantButton(
|
||||
text = companionApp.customizeTitle
|
||||
.ifBlank { stringResource(R.string.patchopts_companion_customize) },
|
||||
icon = painterResource(R.drawable.ic_tune),
|
||||
onClick = { PebbleWatchapp.openSettings(context) },
|
||||
enabled = installed,
|
||||
size = RadiantButtonSize.Small,
|
||||
fillWidth = true,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,6 +85,10 @@
|
||||
<string name="settings_header_installation">Installation</string>
|
||||
<string name="settings_header_appearance">Appearance</string>
|
||||
<string name="settings_header_info">Info</string>
|
||||
<string name="patchopts_companion_install">Install watchapp</string>
|
||||
<string name="patchopts_companion_installed">Installed</string>
|
||||
<string name="patchopts_companion_customize">Customize</string>
|
||||
<string name="pebble_app_missing">Couldn\'t open the Pebble app. Is it installed?</string>
|
||||
<string name="setting_theme">Theme</string>
|
||||
<string name="setting_ui_style">UI style</string>
|
||||
<string name="setting_ui_style_desc">Want the old UI back?</string>
|
||||
|
||||
@@ -32,6 +32,7 @@ kotlinx-serialization = "1.10.0"
|
||||
ktor = "3.4.0"
|
||||
libsu = "6.0.0"
|
||||
microg = "0.3.6.244735"
|
||||
pebblekit2 = "1.3.1"
|
||||
shimmer = "1.3.3"
|
||||
shizuku = "13.1.5"
|
||||
smali = "3.0.9"
|
||||
@@ -115,6 +116,7 @@ hiddenApi-stub = { module = "dev.rikka.hidden:stub", version.ref = "hiddenApi-st
|
||||
microg = { module = "org.microg.gms:play-services-safetynet", version.ref = "microg" }
|
||||
desugaring = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugaring" }
|
||||
libsu = { module = "com.aliucord.libsu:core", version.ref = "libsu" }
|
||||
pebblekit2-client = { module = "io.rebble.pebblekit2:client", version.ref = "pebblekit2" }
|
||||
|
||||
[bundles]
|
||||
accompanist = [
|
||||
|
||||
Reference in new issue
Block a user