RL Pebble <3

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

No files matched your search

+1
View File
@@ -190,6 +190,7 @@ dependencies {
implementation(libs.bundles.ktor)
implementation(libs.bundles.shizuku)
implementation(libs.bundles.voyager)
implementation(libs.pebblekit2.client)
implementation(libs.compose.ui.tooling.preview)
debugImplementation(libs.compose.ui.tooling)
+16
View File
@@ -43,6 +43,22 @@
android:name=".installers.pm.PMIntentReceiver"
android:exported="false" />
<!-- Pebble relay, bound by patched TIDAL -->
<service
android:name=".pebble.PebbleRelayService"
android:exported="true"
tools:ignore="ExportedService" />
<!-- Pebble watch messages -->
<service
android:name=".pebble.PebbleWatchListenerService"
android:exported="true"
tools:ignore="ExportedService">
<intent-filter>
<action android:name="io.rebble.pebblekit2.RECEIVE_DATA_FROM_WATCH" />
</intent-filter>
</service>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.provider"
@@ -780,6 +780,30 @@
}
]
},
{
"id": "PebbleIntegration",
"order": 91,
"category": "integration",
"fileNames": [
"integration-pebble-playback.patch",
"integration-pebble-lyrics.patch"
],
"extensionFiles": [
"radiant/PebbleBridge.smali",
"radiant/PebbleBridge$1.smali",
"radiant/PebbleBridge$2.smali"
],
"title": "Pebble Watch",
"description": "Shows synced lyrics on your Pebble Time 2 when listening, Everything is configured and managed in the Pebble App",
"default": false,
"requires": [
"LyricsRlApi"
],
"companionApp": {
"storeId": "02b779dcf9084fecb0375df1",
"storeSource": "https://appstore-api.repebble.com/api"
}
},
{
"id": "DebugMenuUnlock",
"order": 100,
@@ -29,6 +29,9 @@ class PatchManifestStep(private val options: PatchOptions) : Step() {
val wazeDefault = container.getStep<SmaliPatchStep>().specs
.firstOrNull { it.id == "WazeIntegration" }?.defaultEnabled ?: false
val enableWazeIntegration = options.patchStates["WazeIntegration"] ?: wazeDefault
val pebbleDefault = container.getStep<SmaliPatchStep>().specs
.firstOrNull { it.id == "PebbleIntegration" }?.defaultEnabled ?: false
val enablePebbleIntegration = options.patchStates["PebbleIntegration"] ?: pebbleDefault
val patchedManifest = ManifestPatcher.patchManifest(
manifestBytes = manifest,
@@ -36,6 +39,7 @@ class PatchManifestStep(private val options: PatchOptions) : Step() {
appName = options.appName,
debuggable = options.debuggable,
enableWazeIntegration = enableWazeIntegration,
enablePebbleIntegration = enablePebbleIntegration,
)
container.log("Repacking apk with patched manifest")
@@ -21,6 +21,8 @@ object ManifestPatcher {
private const val WAZE_PACKAGE = "com.waze"
private const val WAZE_INIT_RECEIVER = "radiant.WazeInitReceiver"
private const val WAZE_ACTION_INIT = "com.waze.sdk.audio.ACTION_INIT"
// Pebble bridge binds to the Manager
private const val MANAGER_PACKAGE = "com.meowarex.rlmobile"
fun patchManifest(
manifestBytes: ByteArray,
@@ -28,6 +30,7 @@ object ManifestPatcher {
appName: String,
debuggable: Boolean,
enableWazeIntegration: Boolean,
enablePebbleIntegration: Boolean,
): ByteArray {
// Extract original package name to rewrite every reference to it
// (permissions, provider authorities) to the new packageName.
@@ -90,11 +93,12 @@ object ManifestPatcher {
return when (name) {
"queries" -> object : NodeVisitor(nv) {
private var hasWazePackage = false
private var hasManagerPackage = false
override fun child(ns: String?, name: String): NodeVisitor {
val visitor = super.child(ns, name)
return if (enableWazeIntegration && name == "package") {
return if ((enableWazeIntegration || enablePebbleIntegration) && name == "package") {
object : NodeVisitor(visitor) {
override fun attr(
ns: String?,
@@ -106,6 +110,9 @@ object ManifestPatcher {
if (name == "name" && value == WAZE_PACKAGE) {
hasWazePackage = true
}
if (name == "name" && value == MANAGER_PACKAGE) {
hasManagerPackage = true
}
super.attr(ns, name, resourceId, type, value)
}
}
@@ -127,6 +134,18 @@ object ManifestPatcher {
end()
}
}
if (enablePebbleIntegration && !hasManagerPackage) {
super.child(null, "package").apply {
attr(
ANDROID_NAMESPACE,
"name",
android.R.attr.name,
TYPE_STRING,
MANAGER_PACKAGE,
)
end()
}
}
super.end()
}
}
@@ -0,0 +1,40 @@
package com.meowarex.rlmobile.pebble
import android.os.SystemClock
import io.rebble.pebblekit2.client.BasePebbleListenerService
import io.rebble.pebblekit2.common.model.PebbleDictionary
import io.rebble.pebblekit2.common.model.ReceiveResult
import io.rebble.pebblekit2.common.model.WatchIdentifier
import java.util.UUID
// Forwards watch events to the relay
class PebbleWatchListenerService : BasePebbleListenerService() {
override suspend fun onMessageReceived(
watchappUUID: UUID,
data: PebbleDictionary,
watch: WatchIdentifier,
): ReceiveResult {
val receivedAt = SystemClock.elapsedRealtime()
if (watchappUUID != WatchProtocol.APP_UUID) return ReceiveResult.Nack
PebbleRelayService.current?.onWatchMessage(WatchProtocol.ints(data), receivedAt)
return ReceiveResult.Ack
}
override fun onAppOpened(watchappUUID: UUID, watch: WatchIdentifier) {
if (watchappUUID != WatchProtocol.APP_UUID) return
watchappOpen = true
PebbleRelayService.current?.onWatchappOpened()
}
override fun onAppClosed(watchappUUID: UUID, watch: WatchIdentifier) {
if (watchappUUID != WatchProtocol.APP_UUID) return
watchappOpen = false
PebbleRelayService.current?.onWatchappClosed()
}
companion object {
@Volatile
var watchappOpen = false
private set
}
}
@@ -0,0 +1,61 @@
package com.meowarex.rlmobile.pebble
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.net.Uri
import com.meowarex.rlmobile.R
import com.meowarex.rlmobile.ui.screens.patchopts.CompanionAppSpec
import com.meowarex.rlmobile.util.showToast
import io.rebble.pebblekit2.client.DefaultPebbleSender
import io.rebble.pebblekit2.common.model.TimelineResult
// Watchapp install state and store links
object PebbleWatchapp {
private const val PROBE_PIN_ID = "rl-install-probe"
private const val STORE_WEB = "https://apps.repebble.com/"
private const val APPS_TAB = "pebble://navbar/index"
enum class InstallState { Installed, NotInstalled, Unknown }
// Deleting a missing pin reveals install state
suspend fun installState(context: Context): InstallState = runCatching {
DefaultPebbleSender(context.applicationContext).use { sender ->
when (sender.deleteTimelinePin(WatchProtocol.APP_UUID, PROBE_PIN_ID)) {
TimelineResult.FailedUnknownPin, TimelineResult.Success -> InstallState.Installed
TimelineResult.FailedNoPermissions -> InstallState.NotInstalled
else -> InstallState.Unknown
}
}
}.getOrDefault(InstallState.Unknown)
fun canInstall(app: CompanionAppSpec): Boolean = !app.storeId.isNullOrBlank()
fun install(context: Context, app: CompanionAppSpec) = openStorePage(context, app)
// Apps tab, My Apps holds the settings
fun openSettings(context: Context) {
if (!start(context, Intent(Intent.ACTION_VIEW, Uri.parse(APPS_TAB)))) {
context.showToast(R.string.pebble_app_missing)
}
}
// Pebble app store page, else the web listing
private fun openStorePage(context: Context, app: CompanionAppSpec) {
val storeId = app.storeId?.takeIf { it.isNotBlank() } ?: return
val deepLink = Uri.Builder().scheme("pebble").authority("appstore").appendPath(storeId)
.apply { app.storeSource?.let { appendQueryParameter("source", it) } }
.build()
if (start(context, Intent(Intent.ACTION_VIEW, deepLink))) return
if (!start(context, Intent(Intent.ACTION_VIEW, Uri.parse(STORE_WEB + storeId)))) {
context.showToast(R.string.pebble_app_missing)
}
}
private fun start(context: Context, intent: Intent): Boolean = try {
context.startActivity(intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
true
} catch (_: ActivityNotFoundException) {
false
}
}
@@ -0,0 +1,265 @@
package com.meowarex.rlmobile.pebble
import io.rebble.pebblekit2.common.model.PebbleDictionary
import io.rebble.pebblekit2.common.model.PebbleDictionaryItem
import org.json.JSONArray
import org.json.JSONObject
import java.io.ByteArrayOutputStream
import java.util.UUID
import kotlin.math.roundToLong
// Mirrors rl-pebble main.c and emu_driver.py
object WatchProtocol {
val APP_UUID: UUID = UUID.fromString("79d7fe6a-b970-483a-b096-16da10af0433")
// package.json messageKeys order
const val KEY_CMD = 10000
const val KEY_TRACK_ID = 10001
const val KEY_TITLE = 10002
const val KEY_ARTIST = 10003
const val KEY_DURATION = 10004
const val KEY_STATUS = 10005
const val KEY_LINE_COUNT = 10006
const val KEY_CHUNK_INDEX = 10007
const val KEY_CHUNK = 10008
const val KEY_PLAYING = 10009
const val KEY_POSITION = 10010
const val KEY_INBOX_SIZE = 10011
const val KEY_PHONE_TIME = 10012
const val KEY_WATCH_TIME = 10013
const val KEY_PHONE_TIME_2 = 10014
const val KEY_SET_AUTO_OPEN = 10021
const val CMD_TRACK = 1
const val CMD_LINES = 2
const val CMD_STATE = 3
const val CMD_STATUS = 4
const val CMD_SYNC = 5
const val CMD_HELLO = 20
const val CMD_PLAY_PAUSE = 21
const val CMD_NEXT = 22
const val CMD_PREV = 23
const val CMD_SYNC_REQ = 24
const val CMD_SETTINGS = 25
const val STATUS_IDLE = 0
const val STATUS_LOADING = 1
const val STATUS_READY = 2
const val STATUS_NO_LYRICS = 3
const val STATUS_ERROR = 4
private const val SEG_BG = 1 shl 0
private const val SEG_SPACE = 1 shl 1
private const val LINE_RIGHT = 1 shl 0
private val WHITESPACE = Regex("\\s+")
private const val HOLD_FOREVER = Int.MAX_VALUE.toLong()
private const val LINE_ONLY_MS = 800L
private const val MAX_SEGMENTS = 120
class Segment(val time: Long, val duration: Long, val flags: Int, val text: String)
// end is when the line stops being lit
class Line(val start: Long, val end: Long, val segments: List<Segment>, val right: Boolean)
// Syllable level lines, lit until the next lead line.
fun shape(body: String): List<Line> {
val root = JSONObject(body)
val data = root.optJSONArray("data") ?: return emptyList()
val sides = singerSides(root, data)
class Raw(val start: Long, val end: Long, val allBackground: Boolean, val segments: List<Segment>, val right: Boolean)
val raws = ArrayList<Raw>(data.length())
for (i in 0 until data.length()) {
val item = data.optJSONObject(i) ?: continue
val syllabus = item.optJSONArray("syllabus")?.takeIf { it.length() > 0 }
val segments = ArrayList<Segment>()
val start: Long
val end: Long
var allBackground = false
if (syllabus != null) {
val first = syllabus.optJSONObject(0)
val last = syllabus.optJSONObject(syllabus.length() - 1)
start = first?.optLong("time") ?: 0
end = (last?.optLong("time") ?: 0) + (last?.optLong("duration") ?: 0)
allBackground = (0 until syllabus.length()).all {
syllabus.optJSONObject(it)?.optBoolean("isBackground") == true
}
for (j in 0 until syllabus.length()) {
val syllable = syllabus.optJSONObject(j) ?: continue
val raw = text(syllable)
val trimmed = raw.trim()
if (trimmed.isEmpty()) continue
var flags = if (syllable.optBoolean("isBackground")) SEG_BG else 0
if (raw != raw.trimEnd()) flags = flags or SEG_SPACE
segments += Segment(syllable.optLong("time"), syllable.optLong("duration"), flags, trimmed)
}
} else {
// Line timed only, one segment per word
start = (item.optDouble("startTime", 0.0) * 1000).roundToLong()
end = start + LINE_ONLY_MS
val words = text(item).split(WHITESPACE).filter { it.isNotEmpty() }
words.forEachIndexed { index, word ->
segments += Segment(start, 0, if (index < words.lastIndex) SEG_SPACE else 0, word)
}
}
if (segments.isEmpty()) continue
raws += Raw(start, end, allBackground, segments.take(MAX_SEGMENTS), sides?.get(i) == 1)
}
return raws.mapIndexed { index, raw ->
val nextLead = (index + 1 until raws.size).firstOrNull { !raws[it].allBackground }?.let { raws[it].start }
Line(raw.start, (nextLead ?: HOLD_FOREVER).coerceAtLeast(raw.end), raw.segments, raw.right)
}
}
// RL Mobile duet sides, null for one side.
private fun singerSides(root: JSONObject, data: JSONArray): IntArray? {
val agents = root.optJSONObject("metadata")?.optJSONObject("agents")
fun singerOf(i: Int) = data.optJSONObject(i)?.optJSONObject("element")?.optString("singer")?.takeIf { it.isNotEmpty() }
fun typeOf(singer: String): String = agents?.optJSONObject(singer)?.optString("type", "person")
?: when (singer) {
"v1000" -> "group"
"v2000" -> "other"
else -> "person"
}
// Groups left, second person right
val sideOf = HashMap<String, Int>()
var persons = 0
for (i in 0 until data.length()) {
val singer = singerOf(i) ?: continue
if (singer in sideOf) continue
sideOf[singer] = if (typeOf(singer) != "person") 0 else if (++persons == 2) 1 else 0
}
val sides = IntArray(data.length()) { i -> singerOf(i)?.let { sideOf[it] ?: 0 } ?: -1 }
val sided = sides.count { it >= 0 }
val right = sides.count { it == 1 }
// Nearly all right, swap
if (sided > 0 && right * 100 / sided >= 85) {
for (i in sides.indices) if (sides[i] >= 0) sides[i] = 1 - sides[i]
}
return if (sides.any { it == 0 } && sides.any { it == 1 }) sides else null
}
private fun text(obj: JSONObject): String =
obj.optString("romanized").takeIf { it.isNotBlank() } ?: obj.optString("text")
private fun encodeLine(line: Line): ByteArray {
val out = ByteArrayOutputStream()
out.u32(line.start)
out.u32(line.end)
out.write(line.segments.size)
out.write(if (line.right) LINE_RIGHT else 0)
for (segment in line.segments) {
val text = utf8Truncate(segment.text, 255)
out.u16((segment.time - line.start).coerceIn(0, 65535))
out.u16(segment.duration.coerceIn(0, 65535))
out.write(segment.flags)
out.write(text.size)
out.write(text)
}
return out.toByteArray()
}
// Whole lines per chunk, keyed by first index.
fun chunks(lines: List<Line>, budget: Int): List<Pair<Int, ByteArray>> {
val result = ArrayList<Pair<Int, ByteArray>>()
val buf = ByteArrayOutputStream()
var first = 0
lines.forEachIndexed { index, line ->
val encoded = encodeLine(line)
if (buf.size() > 0 && buf.size() + encoded.size > budget) {
result += first to buf.toByteArray()
buf.reset()
first = index
}
buf.write(encoded)
}
if (buf.size() > 0) result += first to buf.toByteArray()
return result
}
fun track(trackId: Long, title: String, artist: String, durationMs: Long, status: Int, lineCount: Int): PebbleDictionary =
mapOf(
key(KEY_CMD) to PebbleDictionaryItem.UInt8(CMD_TRACK),
key(KEY_TRACK_ID) to PebbleDictionaryItem.UInt32(trackId),
key(KEY_TITLE) to PebbleDictionaryItem.Text(String(utf8Truncate(title, 60))),
key(KEY_ARTIST) to PebbleDictionaryItem.Text(String(utf8Truncate(artist, 60))),
key(KEY_DURATION) to PebbleDictionaryItem.UInt32(durationMs.coerceIn(0, 0xFFFFFFFFL)),
key(KEY_STATUS) to PebbleDictionaryItem.UInt8(status),
key(KEY_LINE_COUNT) to PebbleDictionaryItem.UInt16(lineCount),
)
fun lines(trackId: Long, firstIndex: Int, blob: ByteArray): PebbleDictionary = mapOf(
key(KEY_CMD) to PebbleDictionaryItem.UInt8(CMD_LINES),
key(KEY_TRACK_ID) to PebbleDictionaryItem.UInt32(trackId),
key(KEY_CHUNK_INDEX) to PebbleDictionaryItem.UInt16(firstIndex),
key(KEY_CHUNK) to PebbleDictionaryItem.Bytes(blob),
)
// Position at a phone elapsedRealtime.
fun state(trackId: Long, playing: Boolean, positionMs: Long, phoneTimeMs: Long): PebbleDictionary = mapOf(
key(KEY_CMD) to PebbleDictionaryItem.UInt8(CMD_STATE),
key(KEY_TRACK_ID) to PebbleDictionaryItem.UInt32(trackId),
key(KEY_PLAYING) to PebbleDictionaryItem.UInt8(if (playing) 1 else 0),
key(KEY_POSITION) to PebbleDictionaryItem.Int32(positionMs.coerceIn(0, Int.MAX_VALUE.toLong()).toInt()),
key(KEY_PHONE_TIME) to PebbleDictionaryItem.UInt32(phoneTimeMs and 0xFFFFFFFFL),
)
fun sync(watchTime: Long, receivedAt: Long, sentAt: Long): PebbleDictionary = mapOf(
key(KEY_CMD) to PebbleDictionaryItem.UInt8(CMD_SYNC),
key(KEY_WATCH_TIME) to PebbleDictionaryItem.UInt32(watchTime and 0xFFFFFFFFL),
key(KEY_PHONE_TIME) to PebbleDictionaryItem.UInt32(receivedAt and 0xFFFFFFFFL),
key(KEY_PHONE_TIME_2) to PebbleDictionaryItem.UInt32(sentAt and 0xFFFFFFFFL),
)
fun status(trackId: Long, status: Int): PebbleDictionary = mapOf(
key(KEY_CMD) to PebbleDictionaryItem.UInt8(CMD_STATUS),
key(KEY_TRACK_ID) to PebbleDictionaryItem.UInt32(trackId),
key(KEY_STATUS) to PebbleDictionaryItem.UInt8(status),
)
// Integer tuples from the watch.
fun ints(data: PebbleDictionary): Map<Int, Long> = buildMap {
for ((k, item) in data) {
val value = when (item) {
is PebbleDictionaryItem.UInt32 -> item.value.toLong()
is PebbleDictionaryItem.Int32 -> item.value.toLong()
is PebbleDictionaryItem.UInt16 -> item.value.toLong()
is PebbleDictionaryItem.Int16 -> item.value.toLong()
is PebbleDictionaryItem.UInt8 -> item.value.toLong()
is PebbleDictionaryItem.Int8 -> item.value.toLong()
else -> continue
}
put(k.toInt(), value)
}
}
private fun key(key: Int) = key.toUInt()
private fun utf8Truncate(text: String, limit: Int): ByteArray {
val raw = text.toByteArray(Charsets.UTF_8)
if (raw.size <= limit) return raw
var end = limit
// Don't split a code point
while (end > 0 && (raw[end].toInt() and 0xC0) == 0x80) end--
return raw.copyOf(end)
}
private fun ByteArrayOutputStream.u16(value: Long) {
write((value and 0xFF).toInt())
write(((value shr 8) and 0xFF).toInt())
}
private fun ByteArrayOutputStream.u32(value: Long) {
val v = value.coerceIn(0, 0xFFFFFFFFL)
write((v and 0xFF).toInt())
write(((v shr 8) and 0xFF).toInt())
write(((v shr 16) and 0xFF).toInt())
write(((v shr 24) and 0xFF).toInt())
}
}
@@ -22,13 +22,18 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.fromHtml
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.repeatOnLifecycle
import com.meowarex.rlmobile.R
import com.meowarex.rlmobile.pebble.PebbleWatchapp
import com.meowarex.rlmobile.ui.screens.patchopts.OptionSpec
import com.meowarex.rlmobile.ui.screens.patchopts.PatchLock
import com.meowarex.rlmobile.ui.screens.patchopts.PatchOptionState
@@ -135,7 +140,9 @@ fun PatchSelectionAccordion(
val inlineOptions = patch.advancedOptions.filter { it.isInline }
val hasSheetOptions = patch.advancedOptions.any { !it.isInline }
val hasSettings = patch.variants.isNotEmpty() || inlineOptions.isNotEmpty() || hasSheetOptions
val companionApp = patch.companionApp
val hasSettings = patch.variants.isNotEmpty() || inlineOptions.isNotEmpty() || hasSheetOptions ||
companionApp != null
// When a patch is enabled and has settings show a carded view of the settings
val carded = checked && hasSettings
@@ -240,6 +247,56 @@ fun PatchSelectionAccordion(
)
}
}
if (companionApp != null) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
var installState by remember { mutableStateOf(PebbleWatchapp.InstallState.Unknown) }
// Recheck on resume
LaunchedEffect(lifecycleOwner) {
lifecycleOwner.repeatOnLifecycle(Lifecycle.State.RESUMED) {
installState = PebbleWatchapp.installState(context)
}
}
val installed = installState == PebbleWatchapp.InstallState.Installed
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally),
modifier = Modifier.fillMaxWidth(),
) {
FilledTonalButton(
onClick = { PebbleWatchapp.install(context, companionApp) },
enabled = !installed && PebbleWatchapp.canInstall(companionApp),
modifier = Modifier.weight(1f),
) {
Icon(
painter = painterResource(if (installed) R.drawable.ic_check_circle else R.drawable.ic_download),
contentDescription = null,
modifier = Modifier.size(18.dp),
)
Spacer(Modifier.width(8.dp))
Text(
if (installed) {
stringResource(R.string.patchopts_companion_installed)
} else {
companionApp.installTitle.ifBlank { stringResource(R.string.patchopts_companion_install) }
}
)
}
FilledTonalButton(
onClick = { PebbleWatchapp.openSettings(context) },
enabled = installed,
modifier = Modifier.weight(1f),
) {
Icon(
painter = painterResource(R.drawable.ic_tune),
contentDescription = null,
modifier = Modifier.size(18.dp),
)
Spacer(Modifier.width(8.dp))
Text(companionApp.customizeTitle.ifBlank { stringResource(R.string.patchopts_companion_customize) })
}
}
}
}
}
}
@@ -34,6 +34,8 @@ data class PatchSpec(
val advancedOptions: List<OptionSpec> = emptyList(),
val category: String = CATEGORY_PATCH,
val pathLocked: Boolean = false,
/** Watchapp shown under an integration. */
val companionApp: CompanionAppSpec? = null,
) {
val isIntegration: Boolean get() = category == CATEGORY_INTEGRATION
@@ -43,6 +45,17 @@ data class PatchSpec(
}
}
@Immutable
@Serializable
data class CompanionAppSpec(
val installTitle: String = "",
val customizeTitle: String = "",
/** Pebble appstore id. */
val storeId: String? = null,
/** Appstore feed url. */
val storeSource: String? = null,
)
@Immutable
@Serializable
data class VariantSpec(
@@ -19,13 +19,18 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.fromHtml
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.repeatOnLifecycle
import com.meowarex.rlmobile.R
import com.meowarex.rlmobile.pebble.PebbleWatchapp
import com.meowarex.rlmobile.ui.components.radiant.RadiantButton
import com.meowarex.rlmobile.ui.components.radiant.RadiantButtonSize
import com.meowarex.rlmobile.ui.components.radiant.RadiantSwitch
@@ -138,7 +143,9 @@ fun PatchSelectionAccordion(
val inlineOptions = patch.advancedOptions.filter { it.isInline }
val hasSheetOptions = patch.advancedOptions.any { !it.isInline }
val hasSettings = patch.variants.isNotEmpty() || inlineOptions.isNotEmpty() || hasSheetOptions
val companionApp = patch.companionApp
val hasSettings = patch.variants.isNotEmpty() || inlineOptions.isNotEmpty() || hasSheetOptions ||
companionApp != null
// When a patch is enabled and has settings show a carded view of the settings
val carded = checked && hasSettings
@@ -238,6 +245,47 @@ fun PatchSelectionAccordion(
)
}
}
if (companionApp != null) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
var installState by remember { mutableStateOf(PebbleWatchapp.InstallState.Unknown) }
// Recheck on resume
LaunchedEffect(lifecycleOwner) {
lifecycleOwner.repeatOnLifecycle(Lifecycle.State.RESUMED) {
installState = PebbleWatchapp.installState(context)
}
}
val installed = installState == PebbleWatchapp.InstallState.Installed
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally),
modifier = Modifier.fillMaxWidth(),
) {
RadiantButton(
text = if (installed) {
stringResource(R.string.patchopts_companion_installed)
} else {
companionApp.installTitle.ifBlank { stringResource(R.string.patchopts_companion_install) }
},
icon = painterResource(if (installed) R.drawable.ic_check_circle else R.drawable.ic_download),
onClick = { PebbleWatchapp.install(context, companionApp) },
enabled = !installed && PebbleWatchapp.canInstall(companionApp),
size = RadiantButtonSize.Small,
fillWidth = true,
modifier = Modifier.weight(1f),
)
RadiantButton(
text = companionApp.customizeTitle
.ifBlank { stringResource(R.string.patchopts_companion_customize) },
icon = painterResource(R.drawable.ic_tune),
onClick = { PebbleWatchapp.openSettings(context) },
enabled = installed,
size = RadiantButtonSize.Small,
fillWidth = true,
modifier = Modifier.weight(1f),
)
}
}
}
}
}
@@ -85,6 +85,10 @@
<string name="settings_header_installation">Installation</string>
<string name="settings_header_appearance">Appearance</string>
<string name="settings_header_info">Info</string>
<string name="patchopts_companion_install">Install watchapp</string>
<string name="patchopts_companion_installed">Installed</string>
<string name="patchopts_companion_customize">Customize</string>
<string name="pebble_app_missing">Couldn\'t open the Pebble app. Is it installed?</string>
<string name="setting_theme">Theme</string>
<string name="setting_ui_style">UI style</string>
<string name="setting_ui_style_desc">Want the old UI back?</string>
+2
View File
@@ -32,6 +32,7 @@ kotlinx-serialization = "1.10.0"
ktor = "3.4.0"
libsu = "6.0.0"
microg = "0.3.6.244735"
pebblekit2 = "1.3.1"
shimmer = "1.3.3"
shizuku = "13.1.5"
smali = "3.0.9"
@@ -115,6 +116,7 @@ hiddenApi-stub = { module = "dev.rikka.hidden:stub", version.ref = "hiddenApi-st
microg = { module = "org.microg.gms:play-services-safetynet", version.ref = "microg" }
desugaring = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugaring" }
libsu = { module = "com.aliucord.libsu:core", version.ref = "libsu" }
pebblekit2-client = { module = "io.rebble.pebblekit2:client", version.ref = "pebblekit2" }
[bundles]
accompanist = [