mirror of
https://github.com/project-dorado/dorado-hd.git
synced 2026-10-11 21:10:01 +00:00
feat(backlog): deep queue engine, built-in playlists, PIN screen lock
- media/QueueEngine.kt: bounded queue (500) + played-history (100) with back/forward branches, insert-next/move/clear; PlaybackController mirrors mutations to Media3, previous() restarts within 3 s else steps through history, and a PlaybackEvent flow exposes positioned/playing/paused/stopped. - Built-in playlists (BuiltIn-FavoriteTracks/MostPlayedArtists/RecentTracks) derived from hearts, play counts and history; rendered read-only in the playlists pivot and unit-tested. - PIN screen lock (device GemSettingPinLockScene): PBKDF2-HMAC-SHA256 salted hash in DataStore (never plaintext), settings ▸ screen lock set/change/clear, auto-lock toggle, lock-now; LockShade shows a tokenized numeric keypad with a 5-try/30 s limiter and back cannot bypass; no-PIN drag-unlock unchanged. - 30 new tests; suite 1,598/0.
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
package com.heretek.dorado_hd.data.repo
|
||||
|
||||
import com.heretek.dorado_hd.data.model.Rating
|
||||
import com.heretek.dorado_hd.data.model.Track
|
||||
|
||||
/**
|
||||
* The device's read-only built-in playlists — `BuiltIn-FavoriteTracks`,
|
||||
* `BuiltIn-MostPlayedArtists`, `BuiltIn-RecentTracks` (parity audit §5).
|
||||
*
|
||||
* They are derived on demand from data Dorado-HD already owns: the tri-state
|
||||
* hearts ([Rating.HEART]), persisted play counts (`PlayCountStore`) and the
|
||||
* Quickplay play history. Nothing is persisted, so no schema change is needed.
|
||||
* The derivation is pure and unit-tested; the UI re-derives whenever the
|
||||
* playlists pivot opens (or its inputs change), which is the "refresh on open"
|
||||
* behavior.
|
||||
*/
|
||||
object BuiltInPlaylists {
|
||||
const val FAVORITE_TRACKS_ID = "builtin-favorite-tracks"
|
||||
const val MOST_PLAYED_ARTISTS_ID = "builtin-most-played-artists"
|
||||
const val RECENT_TRACKS_ID = "builtin-recent-tracks"
|
||||
|
||||
const val FAVORITE_LIMIT = 50
|
||||
const val MOST_PLAYED_ARTIST_LIMIT = 10
|
||||
const val MOST_PLAYED_TRACK_LIMIT = 100
|
||||
const val RECENT_LIMIT = 25
|
||||
|
||||
/** A resolved, playable built-in playlist. */
|
||||
data class BuiltInPlaylist(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val subLabel: String,
|
||||
val tracks: List<Track>,
|
||||
)
|
||||
|
||||
/**
|
||||
* Every hearted track, by title. The device's favorites list is defined by
|
||||
* the heart rating, not by play count.
|
||||
*/
|
||||
fun favoriteTracks(
|
||||
library: List<Track>,
|
||||
ratings: Map<Long, Int>,
|
||||
limit: Int = FAVORITE_LIMIT,
|
||||
): List<Track> =
|
||||
library
|
||||
.filter { ratings[it.mediaId] == Rating.HEART.value }
|
||||
.sortedWith(compareBy({ it.title.lowercase() }, { it.mediaId }))
|
||||
.take(limit)
|
||||
|
||||
/**
|
||||
* The tracks of the most-played artists, artist rank first then track play
|
||||
* count. Artists with no plays are excluded (the device playlist is
|
||||
* literally about most-played artists).
|
||||
*/
|
||||
fun mostPlayedArtists(
|
||||
library: List<Track>,
|
||||
playCounts: Map<Long, Int>,
|
||||
artistLimit: Int = MOST_PLAYED_ARTIST_LIMIT,
|
||||
trackLimit: Int = MOST_PLAYED_TRACK_LIMIT,
|
||||
): List<Track> {
|
||||
val byArtist = library.groupBy { it.artistId }
|
||||
val ranked = byArtist.entries
|
||||
.map { (artistId, tracks) ->
|
||||
ArtistPlays(
|
||||
artistId = artistId,
|
||||
name = tracks.firstOrNull()?.artist ?: "",
|
||||
plays = tracks.sumOf { playCounts[it.mediaId] ?: 0 },
|
||||
)
|
||||
}
|
||||
.filter { it.plays > 0 }
|
||||
.sortedWith(
|
||||
compareByDescending<ArtistPlays> { it.plays }
|
||||
.thenBy { it.name.lowercase() }
|
||||
.thenBy { it.artistId },
|
||||
)
|
||||
.take(artistLimit)
|
||||
|
||||
val ordered = ArrayList<Track>()
|
||||
for (artist in ranked) {
|
||||
val tracks = byArtist[artist.artistId].orEmpty().sortedWith(
|
||||
compareByDescending<Track> { playCounts[it.mediaId] ?: 0 }
|
||||
.thenBy { it.title.lowercase() }
|
||||
.thenBy { it.mediaId },
|
||||
)
|
||||
ordered += tracks
|
||||
if (ordered.size >= trackLimit) break
|
||||
}
|
||||
return ordered.take(trackLimit)
|
||||
}
|
||||
|
||||
/**
|
||||
* The most recently played distinct tracks, newest first. [recentMediaIds]
|
||||
* is ordered newest → oldest (Quickplay history is already trimmed).
|
||||
*/
|
||||
fun recentTracks(
|
||||
library: List<Track>,
|
||||
recentMediaIds: List<Long>,
|
||||
limit: Int = RECENT_LIMIT,
|
||||
): List<Track> {
|
||||
val byId = library.associateBy { it.mediaId }
|
||||
val seen = HashSet<Long>()
|
||||
val out = ArrayList<Track>()
|
||||
for (id in recentMediaIds) {
|
||||
if (!seen.add(id)) continue
|
||||
val track = byId[id] ?: continue
|
||||
out += track
|
||||
if (out.size >= limit) break
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the three built-ins in device order. Empty lists are omitted so
|
||||
* the pivot never shows a dead row.
|
||||
*/
|
||||
fun derive(
|
||||
library: List<Track>,
|
||||
ratings: Map<Long, Int>,
|
||||
playCounts: Map<Long, Int>,
|
||||
recentMediaIds: List<Long>,
|
||||
): List<BuiltInPlaylist> {
|
||||
val favorites = favoriteTracks(library, ratings)
|
||||
val mostPlayed = mostPlayedArtists(library, playCounts)
|
||||
val recent = recentTracks(library, recentMediaIds)
|
||||
return buildList {
|
||||
if (favorites.isNotEmpty()) {
|
||||
add(
|
||||
BuiltInPlaylist(
|
||||
id = FAVORITE_TRACKS_ID,
|
||||
title = "favorite tracks",
|
||||
subLabel = "built-in · ${favorites.size} songs",
|
||||
tracks = favorites,
|
||||
),
|
||||
)
|
||||
}
|
||||
if (mostPlayed.isNotEmpty()) {
|
||||
add(
|
||||
BuiltInPlaylist(
|
||||
id = MOST_PLAYED_ARTISTS_ID,
|
||||
title = "most played artists",
|
||||
subLabel = "built-in · ${mostPlayed.size} songs",
|
||||
tracks = mostPlayed,
|
||||
),
|
||||
)
|
||||
}
|
||||
if (recent.isNotEmpty()) {
|
||||
add(
|
||||
BuiltInPlaylist(
|
||||
id = RECENT_TRACKS_ID,
|
||||
title = "recent tracks",
|
||||
subLabel = "built-in · ${recent.size} songs",
|
||||
tracks = recent,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class ArtistPlays(val artistId: Long, val name: String, val plays: Int)
|
||||
}
|
||||
@@ -26,6 +26,15 @@ class QuickplayRepository(private val db: DoradoDatabase) {
|
||||
list.map { it.toCard() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Most-recently played track ids, newest first — the input to the built-in
|
||||
* `recent tracks` playlist. History also carries albums/artists etc., so
|
||||
* only track cards are returned.
|
||||
*/
|
||||
fun recentTrackIds(limit: Int = 30): Flow<List<Long>> = history(limit).map { cards ->
|
||||
cards.filter { it.kind == PinKind.TRACK }.map { it.refId }
|
||||
}
|
||||
|
||||
suspend fun pin(kind: PinKind, refId: Long, label: String, subLabel: String, artAlbumId: Long) {
|
||||
db.pinDao().pin(
|
||||
PinEntity(
|
||||
|
||||
@@ -6,9 +6,16 @@ import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import com.heretek.dorado_hd.data.security.PinLock
|
||||
import com.heretek.dorado_hd.design.DoradoAccent
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
private val Context.dataStore by preferencesDataStore(name = "dorado_hd_settings")
|
||||
|
||||
@@ -42,7 +49,20 @@ data class DoradoSettings(
|
||||
val crossfadeSeconds: Int = 0,
|
||||
/** M10 — sleep-timer preset in minutes (0 = no timer armed). */
|
||||
val sleepTimerMinutes: Int = 0,
|
||||
)
|
||||
/**
|
||||
* Screen lock (device `GemSettingPinLockScene`). Only the PBKDF2 hash,
|
||||
* salt and iteration count are persisted — never the PIN. An empty
|
||||
* [pinHash] means no PIN is configured.
|
||||
*/
|
||||
val pinHash: String = "",
|
||||
val pinSalt: String = "",
|
||||
val pinIterations: Int = PinLock.DEFAULT_ITERATIONS,
|
||||
/** Require the PIN after the app is backgrounded (vs. "lock now" only). */
|
||||
val autoLockEnabled: Boolean = true,
|
||||
) {
|
||||
/** True when a screen-lock credential is stored. */
|
||||
val hasPin: Boolean get() = pinHash.isNotEmpty()
|
||||
}
|
||||
|
||||
class SettingsRepository(private val context: Context) {
|
||||
private object Keys {
|
||||
@@ -67,6 +87,21 @@ class SettingsRepository(private val context: Context) {
|
||||
val EQ_PRESET = stringPreferencesKey("eq_preset")
|
||||
val CROSSFADE_SECONDS = intPreferencesKey("crossfade_seconds")
|
||||
val SLEEP_TIMER_MINUTES = intPreferencesKey("sleep_timer_minutes")
|
||||
val PIN_HASH = stringPreferencesKey("pin_hash")
|
||||
val PIN_SALT = stringPreferencesKey("pin_salt")
|
||||
val PIN_ITERATIONS = intPreferencesKey("pin_iterations")
|
||||
val AUTO_LOCK = booleanPreferencesKey("auto_lock")
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory "lock now" signal (not persisted). Settings ▸ device emits it;
|
||||
* `DoradoRoot` collects it and raises the wake shade.
|
||||
*/
|
||||
private val _lockRequests = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
|
||||
val lockRequests: SharedFlow<Unit> = _lockRequests.asSharedFlow()
|
||||
|
||||
fun requestLockNow() {
|
||||
_lockRequests.tryEmit(Unit)
|
||||
}
|
||||
|
||||
val settings: Flow<DoradoSettings> = context.dataStore.data.map { p ->
|
||||
@@ -92,6 +127,10 @@ class SettingsRepository(private val context: Context) {
|
||||
eqPreset = p[Keys.EQ_PRESET] ?: "FLAT",
|
||||
crossfadeSeconds = p[Keys.CROSSFADE_SECONDS] ?: 0,
|
||||
sleepTimerMinutes = p[Keys.SLEEP_TIMER_MINUTES] ?: 0,
|
||||
pinHash = p[Keys.PIN_HASH] ?: "",
|
||||
pinSalt = p[Keys.PIN_SALT] ?: "",
|
||||
pinIterations = p[Keys.PIN_ITERATIONS] ?: PinLock.DEFAULT_ITERATIONS,
|
||||
autoLockEnabled = p[Keys.AUTO_LOCK] ?: true,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -118,4 +157,47 @@ class SettingsRepository(private val context: Context) {
|
||||
suspend fun setEqPreset(value: String) = context.dataStore.edit { it[Keys.EQ_PRESET] = value }
|
||||
suspend fun setCrossfadeSeconds(value: Int) = context.dataStore.edit { it[Keys.CROSSFADE_SECONDS] = value }
|
||||
suspend fun setSleepTimerMinutes(value: Int) = context.dataStore.edit { it[Keys.SLEEP_TIMER_MINUTES] = value }
|
||||
|
||||
// ---- screen lock (device GemSettingPinLockScene / HudPinLockScene) ----
|
||||
|
||||
suspend fun setAutoLock(enabled: Boolean) = context.dataStore.edit { it[Keys.AUTO_LOCK] = enabled }
|
||||
|
||||
/**
|
||||
* Validates and stores a fresh salted PBKDF2 credential. The PIN itself is
|
||||
* never written. Returns false when [pin] is not 4–6 digits.
|
||||
*/
|
||||
suspend fun setPin(pin: String): Boolean {
|
||||
if (!PinLock.isValid(pin)) return false
|
||||
val stored = withContext(Dispatchers.Default) { PinLock.hashNew(pin) }
|
||||
context.dataStore.edit {
|
||||
it[Keys.PIN_HASH] = PinLock.encode(stored.hash)
|
||||
it[Keys.PIN_SALT] = PinLock.encode(stored.salt)
|
||||
it[Keys.PIN_ITERATIONS] = stored.iterations
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
suspend fun clearPin() = context.dataStore.edit {
|
||||
it.remove(Keys.PIN_HASH)
|
||||
it[Keys.PIN_SALT] = ""
|
||||
it[Keys.PIN_ITERATIONS] = PinLock.DEFAULT_ITERATIONS
|
||||
}
|
||||
|
||||
/** Verifies a PIN against the stored hash. True when no PIN is configured. */
|
||||
suspend fun verifyPin(pin: String): Boolean {
|
||||
val current = settings.first()
|
||||
if (!current.hasPin) return true
|
||||
return withContext(Dispatchers.Default) {
|
||||
runCatching {
|
||||
PinLock.verify(
|
||||
pin,
|
||||
PinLock.Stored(
|
||||
hash = PinLock.decode(current.pinHash),
|
||||
salt = PinLock.decode(current.pinSalt),
|
||||
iterations = current.pinIterations,
|
||||
),
|
||||
)
|
||||
}.getOrDefault(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
package com.heretek.dorado_hd.data.security
|
||||
|
||||
import java.security.MessageDigest
|
||||
import java.security.SecureRandom
|
||||
import java.util.Base64
|
||||
import javax.crypto.SecretKeyFactory
|
||||
import javax.crypto.spec.PBEKeySpec
|
||||
|
||||
/**
|
||||
* Pure PIN hashing/verification for the device screen lock (canon §5,
|
||||
* parity audit §9 `GemSettingPinLockScene` / `HudPinLockScene`).
|
||||
*
|
||||
* A PIN is never stored or round-tripped: only a salted PBKDF2-HMAC-SHA256
|
||||
* hash plus its parameters are persisted (as base64 strings in DataStore).
|
||||
* [verify] recomputes the candidate hash and compares it in constant time.
|
||||
* Android's app-private DataStore is the storage boundary; this object touches
|
||||
* no I/O and is unit-testable on the JVM.
|
||||
*/
|
||||
object PinLock {
|
||||
const val MIN_LENGTH = 4
|
||||
const val MAX_LENGTH = 6
|
||||
const val DEFAULT_ITERATIONS = 120_000
|
||||
const val KEY_LENGTH_BITS = 256
|
||||
const val SALT_LENGTH_BYTES = 16
|
||||
|
||||
/** A stored credential: hash + salt + the iteration count used to derive it. */
|
||||
data class Stored(val hash: ByteArray, val salt: ByteArray, val iterations: Int) {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other !is Stored) return false
|
||||
return iterations == other.iterations &&
|
||||
hash.contentEquals(other.hash) &&
|
||||
salt.contentEquals(other.salt)
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = hash.contentHashCode()
|
||||
result = 31 * result + salt.contentHashCode()
|
||||
result = 31 * result + iterations
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
/** 4–6 decimal digits, the device's accepted PIN shape. */
|
||||
fun isValid(pin: String): Boolean =
|
||||
pin.length in MIN_LENGTH..MAX_LENGTH && pin.all { it in '0'..'9' }
|
||||
|
||||
fun newSalt(random: SecureRandom = SecureRandom()): ByteArray =
|
||||
ByteArray(SALT_LENGTH_BYTES).also(random::nextBytes)
|
||||
|
||||
fun hash(pin: String, salt: ByteArray, iterations: Int = DEFAULT_ITERATIONS): ByteArray {
|
||||
require(isValid(pin)) { "pin must be $MIN_LENGTH-$MAX_LENGTH digits" }
|
||||
require(iterations >= 1) { "iterations must be >= 1" }
|
||||
val spec = PBEKeySpec(pin.toCharArray(), salt, iterations, KEY_LENGTH_BITS)
|
||||
return try {
|
||||
SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256").generateSecret(spec).encoded
|
||||
} finally {
|
||||
spec.clearPassword()
|
||||
}
|
||||
}
|
||||
|
||||
/** Derives a fresh credential with a new random salt. */
|
||||
fun hashNew(
|
||||
pin: String,
|
||||
random: SecureRandom = SecureRandom(),
|
||||
iterations: Int = DEFAULT_ITERATIONS,
|
||||
): Stored {
|
||||
val salt = newSalt(random)
|
||||
return Stored(hash(pin, salt, iterations), salt, iterations)
|
||||
}
|
||||
|
||||
fun verify(pin: String, stored: Stored): Boolean {
|
||||
if (!isValid(pin)) return false
|
||||
val candidate = hash(pin, stored.salt, stored.iterations)
|
||||
return MessageDigest.isEqual(candidate, stored.hash)
|
||||
}
|
||||
|
||||
/** Base64 for DataStore transport. Never encodes the PIN itself. */
|
||||
fun encode(bytes: ByteArray): String = Base64.getEncoder().encodeToString(bytes)
|
||||
|
||||
fun decode(value: String): ByteArray = Base64.getDecoder().decode(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Consecutive-failure rate limiter for PIN entry: after [maxAttempts] wrong
|
||||
* PINs the keypad is blocked for [cooldownMs]. Pure and clock-injectable so
|
||||
* the policy is unit-testable without sleeping.
|
||||
*/
|
||||
class PinAttemptLimiter(
|
||||
val maxAttempts: Int = 5,
|
||||
val cooldownMs: Long = 30_000L,
|
||||
private val clock: () -> Long = System::currentTimeMillis,
|
||||
) {
|
||||
var failures: Int = 0
|
||||
private set
|
||||
|
||||
private var lockedUntilMs: Long = 0L
|
||||
|
||||
fun remainingCooldownMs(): Long = (lockedUntilMs - clock()).coerceAtLeast(0L)
|
||||
|
||||
fun isLocked(): Boolean = remainingCooldownMs() > 0L
|
||||
|
||||
fun canAttempt(): Boolean = !isLocked()
|
||||
|
||||
fun onFailure() {
|
||||
if (isLocked()) return
|
||||
failures++
|
||||
if (failures >= maxAttempts) {
|
||||
lockedUntilMs = clock() + cooldownMs
|
||||
failures = 0
|
||||
}
|
||||
}
|
||||
|
||||
fun onSuccess() {
|
||||
failures = 0
|
||||
lockedUntilMs = 0L
|
||||
}
|
||||
}
|
||||
@@ -9,14 +9,19 @@ import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectVerticalDragGestures
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.text.BasicText
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@@ -38,11 +43,14 @@ import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.heretek.dorado_hd.data.security.PinAttemptLimiter
|
||||
import com.heretek.dorado_hd.data.security.PinLock
|
||||
import com.heretek.dorado_hd.design.LocalDoradoColors
|
||||
import com.heretek.dorado_hd.design.Selawik
|
||||
import com.heretek.dorado_hd.design.DoradoMotion
|
||||
import com.heretek.dorado_hd.design.DoradoTokens
|
||||
import java.util.Date
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
@@ -50,11 +58,17 @@ import kotlinx.coroutines.launch
|
||||
* leaves the foreground; slide it up to reveal the home screen, exactly as
|
||||
* the device did over the user's wallpaper. Translucent, so the wallpaper
|
||||
* shows through; user drags the shade up to dismiss.
|
||||
*
|
||||
* When [pinRequired] the slide gesture is replaced by the device's numeric
|
||||
* PIN keypad (`HudPinLockScene`): tokenized, zero radius, no Material chrome.
|
||||
* Wrong entries are rate-limited by [PinAttemptLimiter] (5 tries → 30 s).
|
||||
*/
|
||||
@Composable
|
||||
fun LockShade(
|
||||
visible: Boolean,
|
||||
onUnlock: () -> Unit,
|
||||
pinRequired: Boolean = false,
|
||||
verifyPin: suspend (String) -> Boolean = { true },
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val colors = LocalDoradoColors.current
|
||||
@@ -63,9 +77,9 @@ fun LockShade(
|
||||
val offset = remember { Animatable(0f) }
|
||||
var now by remember { mutableStateOf(System.currentTimeMillis()) }
|
||||
|
||||
// While shaded, system Back dismisses the shade instead of backgrounding.
|
||||
// While shaded, system Back dismisses the shade — but never bypasses a PIN.
|
||||
if (visible) {
|
||||
androidx.activity.compose.BackHandler { onUnlock() }
|
||||
androidx.activity.compose.BackHandler { if (!pinRequired) onUnlock() }
|
||||
}
|
||||
|
||||
// System wallpaper (one-shot, decoded off the main thread). Null if the
|
||||
@@ -100,28 +114,34 @@ fun LockShade(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.graphicsLayer { translationY = -offset.value }
|
||||
.pointerInput(Unit) {
|
||||
detectVerticalDragGestures(
|
||||
onVerticalDrag = { change, amount ->
|
||||
change.consume()
|
||||
scope.launch { offset.snapTo((offset.value - amount).coerceIn(0f, unlockPx * 1.4f)) }
|
||||
},
|
||||
onDragEnd = {
|
||||
if (offset.value > unlockPx) {
|
||||
// Let the slide-off animation finish before the
|
||||
// shade is dismissed (the old code flipped
|
||||
// visibility immediately, so the slide was never
|
||||
// seen).
|
||||
scope.launch {
|
||||
offset.animateTo(unlockPx * 1.6f, DoradoMotion.pivot())
|
||||
onUnlock()
|
||||
}
|
||||
} else {
|
||||
scope.launch { offset.animateTo(0f, DoradoMotion.pivot()) }
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
.then(
|
||||
if (pinRequired) {
|
||||
Modifier
|
||||
} else {
|
||||
Modifier.pointerInput(Unit) {
|
||||
detectVerticalDragGestures(
|
||||
onVerticalDrag = { change, amount ->
|
||||
change.consume()
|
||||
scope.launch { offset.snapTo((offset.value - amount).coerceIn(0f, unlockPx * 1.4f)) }
|
||||
},
|
||||
onDragEnd = {
|
||||
if (offset.value > unlockPx) {
|
||||
// Let the slide-off animation finish before the
|
||||
// shade is dismissed (the old code flipped
|
||||
// visibility immediately, so the slide was never
|
||||
// seen).
|
||||
scope.launch {
|
||||
offset.animateTo(unlockPx * 1.6f, DoradoMotion.pivot())
|
||||
onUnlock()
|
||||
}
|
||||
} else {
|
||||
scope.launch { offset.animateTo(0f, DoradoMotion.pivot()) }
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
),
|
||||
) {
|
||||
// Wallpaper underneath the translucent scrim — faithful to the
|
||||
// device's "user wallpaper behind a software shade" affordance.
|
||||
@@ -137,44 +157,53 @@ fun LockShade(
|
||||
// Translucent scrim (alpha 0.6) so the time/date is readable on
|
||||
// any wallpaper, but the wallpaper still bleeds through.
|
||||
Box(Modifier.fillMaxSize().background(colors.background.copy(alpha = 0.6f)))
|
||||
Column(
|
||||
Modifier
|
||||
.align(Alignment.Center)
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = DoradoTokens.EDGE.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
BasicText(
|
||||
text = DateFormat.getTimeFormat(context).format(Date(now)),
|
||||
style = TextStyle(
|
||||
fontFamily = Selawik,
|
||||
fontWeight = FontWeight.Light,
|
||||
fontSize = DoradoTokens.TYPE_HEADER_CROPPED.sp,
|
||||
color = colors.textPrimary,
|
||||
),
|
||||
|
||||
if (pinRequired) {
|
||||
PinLockPanel(
|
||||
verifyPin = verifyPin,
|
||||
onUnlock = onUnlock,
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
} else {
|
||||
Column(
|
||||
Modifier
|
||||
.align(Alignment.Center)
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = DoradoTokens.EDGE.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
BasicText(
|
||||
text = DateFormat.getTimeFormat(context).format(Date(now)),
|
||||
style = TextStyle(
|
||||
fontFamily = Selawik,
|
||||
fontWeight = FontWeight.Light,
|
||||
fontSize = DoradoTokens.TYPE_HEADER_CROPPED.sp,
|
||||
color = colors.textPrimary,
|
||||
),
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
BasicText(
|
||||
text = DateFormat.getDateFormat(context).format(Date(now)),
|
||||
style = TextStyle(
|
||||
fontFamily = Selawik,
|
||||
fontSize = DoradoTokens.TYPE_LIST.sp,
|
||||
color = colors.textSecondary,
|
||||
),
|
||||
)
|
||||
}
|
||||
BasicText(
|
||||
text = DateFormat.getDateFormat(context).format(Date(now)),
|
||||
text = "slide up to unlock",
|
||||
style = TextStyle(
|
||||
fontFamily = Selawik,
|
||||
fontSize = DoradoTokens.TYPE_LIST.sp,
|
||||
fontSize = DoradoTokens.TYPE_NOW_META.sp,
|
||||
color = colors.textSecondary,
|
||||
),
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 32.dp),
|
||||
)
|
||||
}
|
||||
BasicText(
|
||||
text = "slide up to unlock",
|
||||
style = TextStyle(
|
||||
fontFamily = Selawik,
|
||||
fontSize = DoradoTokens.TYPE_NOW_META.sp,
|
||||
color = colors.textSecondary,
|
||||
),
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 32.dp),
|
||||
)
|
||||
BasicText(
|
||||
text = "dorado hd",
|
||||
style = TextStyle(
|
||||
@@ -191,6 +220,138 @@ fun LockShade(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The device PIN keypad. Digits only; submit with `ok`, back out with `del`.
|
||||
* Failures feed [PinAttemptLimiter] so five wrong entries block the pad for
|
||||
* 30 s. No plaintext PIN is retained after a submission.
|
||||
*/
|
||||
@Composable
|
||||
private fun PinLockPanel(
|
||||
verifyPin: suspend (String) -> Boolean,
|
||||
onUnlock: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val colors = LocalDoradoColors.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val limiter = remember { PinAttemptLimiter() }
|
||||
var entered by remember { mutableStateOf("") }
|
||||
var message by remember { mutableStateOf<String?>(null) }
|
||||
var cooldownMs by remember { mutableStateOf(0L) }
|
||||
var locked by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(locked) {
|
||||
while (locked) {
|
||||
val remaining = limiter.remainingCooldownMs()
|
||||
cooldownMs = remaining
|
||||
if (remaining <= 0L) locked = false else delay(250)
|
||||
}
|
||||
}
|
||||
|
||||
fun submit() {
|
||||
if (entered.length < PinLock.MIN_LENGTH) {
|
||||
message = "enter ${PinLock.MIN_LENGTH}-${PinLock.MAX_LENGTH} digits"
|
||||
return
|
||||
}
|
||||
val candidate = entered
|
||||
entered = ""
|
||||
scope.launch {
|
||||
if (verifyPin(candidate)) {
|
||||
limiter.onSuccess()
|
||||
message = null
|
||||
onUnlock()
|
||||
} else {
|
||||
limiter.onFailure()
|
||||
if (limiter.isLocked()) {
|
||||
message = null
|
||||
locked = true
|
||||
} else {
|
||||
message = "incorrect pin"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = modifier.padding(horizontal = DoradoTokens.EDGE.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
BasicText(
|
||||
text = "enter pin",
|
||||
style = TextStyle(
|
||||
fontFamily = Selawik,
|
||||
fontSize = DoradoTokens.TYPE_LIST.sp,
|
||||
color = colors.textSecondary,
|
||||
),
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
repeat(PinLock.MAX_LENGTH) { index ->
|
||||
val filled = index < entered.length
|
||||
Box(
|
||||
Modifier
|
||||
.size(10.dp)
|
||||
.background(if (filled) colors.accent else Color.Transparent)
|
||||
.border(0.5.dp, colors.border),
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
val status = when {
|
||||
locked -> "locked · ${cooldownMs / 1000 + 1}s"
|
||||
message != null -> message!!
|
||||
else -> " "
|
||||
}
|
||||
BasicText(
|
||||
text = status,
|
||||
style = TextStyle(
|
||||
fontFamily = Selawik,
|
||||
fontSize = DoradoTokens.TYPE_LIST_SECONDARY.sp,
|
||||
color = if (locked) colors.accent else colors.textSecondary,
|
||||
),
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
val rows = listOf(
|
||||
listOf("1", "2", "3"),
|
||||
listOf("4", "5", "6"),
|
||||
listOf("7", "8", "9"),
|
||||
listOf("del", "0", "ok"),
|
||||
)
|
||||
rows.forEach { keys ->
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
keys.forEach { key ->
|
||||
val enabled = !locked &&
|
||||
!(key == "ok" && entered.length < PinLock.MIN_LENGTH) &&
|
||||
!(key == "del" && entered.isEmpty())
|
||||
Box(
|
||||
Modifier
|
||||
.size(width = 46.dp, height = 30.dp)
|
||||
.background(colors.tile)
|
||||
.border(0.5.dp, colors.border)
|
||||
.clickable(enabled = enabled) {
|
||||
when (key) {
|
||||
"del" -> entered = entered.dropLast(1)
|
||||
"ok" -> submit()
|
||||
else -> if (entered.length < PinLock.MAX_LENGTH) entered += key
|
||||
}
|
||||
},
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
BasicText(
|
||||
text = key,
|
||||
style = TextStyle(
|
||||
fontFamily = Selawik,
|
||||
fontSize = DoradoTokens.TYPE_LIST.sp,
|
||||
color = if (enabled) colors.textPrimary else colors.textInactive,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun wallpaperDrawable(context: android.content.Context): androidx.compose.ui.graphics.ImageBitmap? {
|
||||
return try {
|
||||
|
||||
@@ -99,6 +99,20 @@ class PlaybackController(
|
||||
/** Media ids of the parts of the audiobook queue, for the reset guard. */
|
||||
private var audiobookPartIds: Set<Long> = emptySet()
|
||||
|
||||
/**
|
||||
* Pure queue order + played-history stack (device `CMediaQueueBase` /
|
||||
* `CTrackListQueue`). Media3 remains the playback engine; every queue
|
||||
* mutation is mirrored through [applyQueueMutation].
|
||||
*/
|
||||
private val queueEngine = QueueEngine<Track>(idOf = { it.mediaId })
|
||||
|
||||
/**
|
||||
* The most recent device-style playback event (`ZMediaQueue/MediaItem*`).
|
||||
* In-memory only; a StateFlow so consumers always see the latest event.
|
||||
*/
|
||||
private val _playbackEvent = MutableStateFlow<PlaybackEvent?>(null)
|
||||
val playbackEvent: StateFlow<PlaybackEvent?> = _playbackEvent.asStateFlow()
|
||||
|
||||
/** Sleep-timer countdown in ms; 0 when no timer is armed. */
|
||||
private val _sleepRemainingMs = MutableStateFlow(0L)
|
||||
val sleepRemainingMs: StateFlow<Long> = _sleepRemainingMs.asStateFlow()
|
||||
@@ -120,12 +134,18 @@ class PlaybackController(
|
||||
private val listener = object : Player.Listener {
|
||||
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
||||
_isPlaying.value = isPlaying
|
||||
val id = _nowPlaying.value?.mediaId ?: return
|
||||
_playbackEvent.value = if (isPlaying) PlaybackEvent.Playing(id) else PlaybackEvent.Paused(id)
|
||||
}
|
||||
|
||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||
if (playbackState == Player.STATE_READY) {
|
||||
_durationMs.value = controller?.duration ?: 0L
|
||||
}
|
||||
if (playbackState == Player.STATE_ENDED) {
|
||||
val id = _nowPlaying.value?.mediaId ?: return
|
||||
_playbackEvent.value = PlaybackEvent.Stopped(id)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
|
||||
@@ -137,8 +157,10 @@ class PlaybackController(
|
||||
val previousReached = candidateReachedThreshold
|
||||
val index = controller?.currentMediaItemIndex ?: return
|
||||
_currentIndex.value = index
|
||||
queueEngine.setCurrentIndex(index)
|
||||
val track = _queue.value.getOrNull(index)
|
||||
_nowPlaying.value = track
|
||||
if (track != null) queueEngine.recordPlayed(track)
|
||||
_durationMs.value = controller?.duration ?: 0L
|
||||
candidateReachedThreshold = false
|
||||
// Parts of the audiobook keep the chosen reading speed; anything
|
||||
@@ -178,6 +200,13 @@ class PlaybackController(
|
||||
if (player != null && _isPlaying.value) {
|
||||
val position = player.currentPosition.coerceAtLeast(0)
|
||||
_positionMs.value = position
|
||||
_nowPlaying.value?.let { track ->
|
||||
_playbackEvent.value = PlaybackEvent.Positioned(
|
||||
mediaId = track.mediaId,
|
||||
positionMs = position,
|
||||
durationMs = _durationMs.value,
|
||||
)
|
||||
}
|
||||
if (reachedScrobbleThreshold(position, _durationMs.value)) {
|
||||
candidateReachedThreshold = true
|
||||
}
|
||||
@@ -316,11 +345,14 @@ class PlaybackController(
|
||||
if (_speed.value != 1.0f) setSpeed(1.0f)
|
||||
}
|
||||
baseOrder = tracks
|
||||
_queue.value = tracks
|
||||
queueEngine.reset(tracks, startIndex)
|
||||
_queue.value = queueEngine.items
|
||||
_currentIndex.value = queueEngine.currentIndex
|
||||
_source.value = source
|
||||
val safeStart = queueEngine.currentIndex.coerceAtLeast(0)
|
||||
val items = tracks.map { it.toMediaItem() }
|
||||
val player = controller ?: return
|
||||
player.setMediaItems(items, startIndex, 0L)
|
||||
player.setMediaItems(items, safeStart, 0L)
|
||||
player.prepare()
|
||||
fadeOutTriggered = false
|
||||
if (FadeRamp.effectiveMs(crossfadeMs()) > 0L) player.volume = 0f
|
||||
@@ -356,13 +388,18 @@ class PlaybackController(
|
||||
controller?.setPlaybackSpeed(value)
|
||||
}
|
||||
|
||||
/** Appends [track] to the end of the queue (the "play next" long-press action). */
|
||||
fun enqueue(track: Track) {
|
||||
val player = controller ?: return
|
||||
player.addMediaItem(track.toMediaItem())
|
||||
_queue.value = _queue.value + track
|
||||
applyQueueMutation(queueEngine.append(track))
|
||||
if (baseOrder.isEmpty()) baseOrder = _queue.value
|
||||
}
|
||||
|
||||
/** Inserts [track] immediately after the current item (device showlist insert). */
|
||||
fun insertNext(track: Track) {
|
||||
applyQueueMutation(queueEngine.insertNext(track))
|
||||
if (!_shuffle.value) baseOrder = _queue.value
|
||||
}
|
||||
|
||||
fun toggle() {
|
||||
val player = controller ?: return
|
||||
if (player.isPlaying) {
|
||||
@@ -380,8 +417,27 @@ class PlaybackController(
|
||||
skipWithFade { it.seekToNextMediaItem() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Device previous behavior: within the first few seconds step back through
|
||||
* the played-history stack; after that restart the current track. Falls
|
||||
* back to a restart when the history entry is no longer queued.
|
||||
*/
|
||||
fun previous() {
|
||||
skipWithFade { it.seekToPreviousMediaItem() }
|
||||
val position = _positionMs.value
|
||||
if (position > PREVIOUS_RESTART_THRESHOLD_MS) {
|
||||
seekTo(0L)
|
||||
return
|
||||
}
|
||||
val id = queueEngine.back() ?: run {
|
||||
seekTo(0L)
|
||||
return
|
||||
}
|
||||
val index = _queue.value.indexOfFirst { it.mediaId == id }
|
||||
if (index >= 0) {
|
||||
skipWithFade { it.seekTo(index, 0L) }
|
||||
} else {
|
||||
seekTo(0L)
|
||||
}
|
||||
}
|
||||
|
||||
fun seekTo(positionMs: Long) {
|
||||
@@ -395,10 +451,52 @@ class PlaybackController(
|
||||
|
||||
/** Removes the queue entry at [index] (the device's showlist/queue list). */
|
||||
fun removeAt(index: Int) {
|
||||
val queueNow = _queue.value
|
||||
if (index !in queueNow.indices) return
|
||||
controller?.removeMediaItem(index)
|
||||
_queue.value = queueNow.toMutableList().also { it.removeAt(index) }
|
||||
val removed = _queue.value.getOrNull(index) ?: return
|
||||
applyQueueMutation(queueEngine.removeAt(index))
|
||||
baseOrder = baseOrder.filterNot { it.mediaId == removed.mediaId }
|
||||
}
|
||||
|
||||
/** Reorders the queue (device `ZDKMedia_Queue_MoveTo`). */
|
||||
fun moveInQueue(from: Int, to: Int) {
|
||||
applyQueueMutation(queueEngine.move(from, to))
|
||||
if (!_shuffle.value) baseOrder = _queue.value
|
||||
}
|
||||
|
||||
/** Empties the queue and its played history. */
|
||||
fun clearQueue() {
|
||||
applyQueueMutation(queueEngine.clear())
|
||||
baseOrder = emptyList()
|
||||
}
|
||||
|
||||
/** Snapshot of the played-history ids (oldest → newest). */
|
||||
fun playedHistory(): List<Long> = queueEngine.history
|
||||
|
||||
/** True while the device-style `previous()` would step back a track. */
|
||||
fun canGoBackInHistory(): Boolean = queueEngine.canGoBack()
|
||||
|
||||
/** Mirrors a pure [QueueMutation] onto Media3 and the exposed StateFlows. */
|
||||
private fun applyQueueMutation(mutation: QueueMutation<Track>) {
|
||||
val player = controller
|
||||
when (mutation) {
|
||||
is QueueMutation.None -> Unit
|
||||
is QueueMutation.Append -> {
|
||||
if (player != null) {
|
||||
mutation.evictAt?.let { player.removeMediaItem(it) }
|
||||
player.addMediaItem(mutation.item.toMediaItem())
|
||||
}
|
||||
}
|
||||
is QueueMutation.Insert -> {
|
||||
if (player != null) {
|
||||
mutation.evictAt?.let { player.removeMediaItem(it) }
|
||||
player.addMediaItem(mutation.index, mutation.item.toMediaItem())
|
||||
}
|
||||
}
|
||||
is QueueMutation.Move -> player?.moveMediaItem(mutation.from, mutation.to)
|
||||
is QueueMutation.Remove -> player?.removeMediaItem(mutation.index)
|
||||
is QueueMutation.Clear -> player?.clearMediaItems()
|
||||
}
|
||||
_queue.value = queueEngine.items
|
||||
_currentIndex.value = queueEngine.currentIndex
|
||||
}
|
||||
|
||||
fun setShuffle(enabled: Boolean) {
|
||||
@@ -416,6 +514,8 @@ class PlaybackController(
|
||||
listOfNotNull(current) + rest
|
||||
}
|
||||
_queue.value = newOrder
|
||||
queueEngine.reset(newOrder, 0)
|
||||
_currentIndex.value = queueEngine.currentIndex
|
||||
player.setMediaItems(newOrder.map { it.toMediaItem() }, 0, 0L)
|
||||
player.prepare()
|
||||
if (_isPlaying.value) player.play()
|
||||
@@ -427,6 +527,8 @@ class PlaybackController(
|
||||
val ratings = quickplay.ratings()
|
||||
val newOrder = smartShuffleOrder(baseOrder, ratings, _nowPlaying.value)
|
||||
_queue.value = newOrder
|
||||
queueEngine.reset(newOrder, 0)
|
||||
_currentIndex.value = queueEngine.currentIndex
|
||||
player.setMediaItems(newOrder.map { it.toMediaItem() }, 0, 0L)
|
||||
player.prepare()
|
||||
if (_isPlaying.value) player.play()
|
||||
@@ -460,6 +562,13 @@ class PlaybackController(
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Within this window `previous()` steps back through history; beyond
|
||||
* it the device restarts the current track (Media3's own
|
||||
* `DEFAULT_MAX_SEEK_TO_PREVIOUS_POSITION_MS`).
|
||||
*/
|
||||
const val PREVIOUS_RESTART_THRESHOLD_MS = 3_000L
|
||||
|
||||
/**
|
||||
* Smart DJ ordering (canon §4): the current track leads, hearted
|
||||
* tracks follow, then unrated; broken-heart tracks are skipped.
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.heretek.dorado_hd.media
|
||||
|
||||
/**
|
||||
* In-memory playback state events, analogues of the device's
|
||||
* `ZMediaQueue/MediaItemPositioned|Stopped|Paused|Playing` notifications
|
||||
* (`zmedia_serv.dll`). They are delivered as one [kotlinx.coroutines.flow.StateFlow]
|
||||
* of the most recent event; consumers reconstruct the stream themselves. No
|
||||
* event is persisted.
|
||||
*/
|
||||
sealed interface PlaybackEvent {
|
||||
/** The item the event describes. */
|
||||
val mediaId: Long
|
||||
|
||||
/** Device `MediaItemPlaying`. */
|
||||
data class Playing(override val mediaId: Long) : PlaybackEvent
|
||||
|
||||
/** Device `MediaItemPaused`. */
|
||||
data class Paused(override val mediaId: Long) : PlaybackEvent
|
||||
|
||||
/** Device `MediaItemStopped` — ended, stopped, or the queue was cleared. */
|
||||
data class Stopped(override val mediaId: Long) : PlaybackEvent
|
||||
|
||||
/** Device `MediaItemPositioned` — periodic position report. */
|
||||
data class Positioned(
|
||||
override val mediaId: Long,
|
||||
val positionMs: Long,
|
||||
val durationMs: Long,
|
||||
) : PlaybackEvent
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
package com.heretek.dorado_hd.media
|
||||
|
||||
/**
|
||||
* Pure queue + played-history model, recovered from the Zune HD playback
|
||||
* engine (`zmedia_serv.dll`: `CMediaQueueBase` / `CProgressivePlay` /
|
||||
* `CTrackListQueue`, the `AudioQueueCapacity` / `...HistoryCapacity` registry
|
||||
* values, and the `ZMediaQueue/MediaItem*` notifications).
|
||||
*
|
||||
* The engine owns the queue order, a bounded played-history stack, and the
|
||||
* capacity-eviction policy. It performs no playback and holds no Android
|
||||
* types, so it is unit-testable in isolation. [PlaybackController] mirrors
|
||||
* every mutation onto Media3.
|
||||
*
|
||||
* **Capacity semantics.** The seed list passed to [reset] is never truncated
|
||||
* (a whole album/playlist is queued at once); [capacity] bounds incremental
|
||||
* growth via [append] / [insertNext] / [insertAt]. When a mutation would
|
||||
* exceed capacity the engine evicts the oldest *already-played* entry, or the
|
||||
* tail when the current item is first, so the currently playing item is never
|
||||
* disturbed (unless the queue holds only it).
|
||||
*
|
||||
* **History semantics.** [recordPlayed] pushes ids onto a bounded stack with
|
||||
* browser-style branches: going [back] then playing something new discards the
|
||||
* abandoned forward branch. [back] / [forward] are pure cursor moves; the
|
||||
* controller decides restart-vs-back using the playback position.
|
||||
*
|
||||
* The device's capacity defaults are not recoverable from the reconstructed
|
||||
* binaries (only the registry key names survive), so the constants below are
|
||||
* documented, generous stand-ins.
|
||||
*/
|
||||
class QueueEngine<T>(
|
||||
val capacity: Int = DEFAULT_CAPACITY,
|
||||
val historyCapacity: Int = DEFAULT_HISTORY_CAPACITY,
|
||||
private val idOf: (T) -> Long,
|
||||
) {
|
||||
init {
|
||||
require(capacity >= 1) { "capacity must be >= 1" }
|
||||
require(historyCapacity >= 1) { "historyCapacity must be >= 1" }
|
||||
}
|
||||
|
||||
private val _items = ArrayList<T>()
|
||||
|
||||
/** A snapshot of the current queue order. */
|
||||
val items: List<T> get() = _items.toList()
|
||||
|
||||
/** Index of the playing item, or -1 when the queue is empty. */
|
||||
var currentIndex: Int = -1
|
||||
private set
|
||||
|
||||
private val _history = ArrayList<Long>()
|
||||
|
||||
/** Played item ids, oldest → newest. */
|
||||
val history: List<Long> get() = _history.toList()
|
||||
|
||||
/** Cursor into [history]; -1 when nothing has been played. */
|
||||
var historyCursor: Int = -1
|
||||
private set
|
||||
|
||||
val size: Int get() = _items.size
|
||||
val isEmpty: Boolean get() = _items.isEmpty()
|
||||
val isNotEmpty: Boolean get() = _items.isNotEmpty()
|
||||
|
||||
/** Replaces the queue wholesale (a fresh `play()`) and clears history. */
|
||||
fun reset(newItems: List<T>, startIndex: Int) {
|
||||
_items.clear()
|
||||
_items.addAll(newItems)
|
||||
currentIndex = if (_items.isEmpty()) -1 else startIndex.coerceIn(0, _items.lastIndex)
|
||||
_history.clear()
|
||||
historyCursor = -1
|
||||
}
|
||||
|
||||
fun setCurrentIndex(index: Int) {
|
||||
if (index in _items.indices) currentIndex = index
|
||||
}
|
||||
|
||||
// ---- played history (device `AudioQueueHistoryCapacity`) ----
|
||||
|
||||
/**
|
||||
* Records that [item] started playing. Re-recording the current history
|
||||
* entry (a replay) is a no-op; stepping onto the entry we already hold
|
||||
* (a `forward()`) advances the cursor instead of branching.
|
||||
*/
|
||||
fun recordPlayed(item: T) {
|
||||
val id = idOf(item)
|
||||
if (historyCursor in _history.indices && _history[historyCursor] == id) return
|
||||
if (historyCursor + 1 <= _history.lastIndex && _history[historyCursor + 1] == id) {
|
||||
historyCursor++
|
||||
return
|
||||
}
|
||||
// A genuinely new play abandons any forward branch.
|
||||
while (_history.size > historyCursor + 1) _history.removeAt(_history.lastIndex)
|
||||
_history.add(id)
|
||||
if (_history.size > historyCapacity) {
|
||||
repeat(_history.size - historyCapacity) { _history.removeAt(0) }
|
||||
}
|
||||
historyCursor = _history.lastIndex
|
||||
}
|
||||
|
||||
fun canGoBack(): Boolean = historyCursor > 0
|
||||
fun canGoForward(): Boolean = historyCursor in 0 until _history.lastIndex
|
||||
|
||||
/** Steps back in history and returns the id, or null at the start. */
|
||||
fun back(): Long? {
|
||||
if (!canGoBack()) return null
|
||||
historyCursor--
|
||||
return _history[historyCursor]
|
||||
}
|
||||
|
||||
/** Steps forward in history and returns the id, or null at the tip. */
|
||||
fun forward(): Long? {
|
||||
if (!canGoForward()) return null
|
||||
historyCursor++
|
||||
return _history[historyCursor]
|
||||
}
|
||||
|
||||
// ---- queue mutation ----
|
||||
|
||||
/** Appends [item], evicting an already-played entry first if at capacity. */
|
||||
fun append(item: T): QueueMutation<T> {
|
||||
val evictAt = evictionIndex()
|
||||
if (evictAt != null) removeInternal(evictAt)
|
||||
_items.add(item)
|
||||
return QueueMutation.Append(item, evictAt)
|
||||
}
|
||||
|
||||
/** Inserts [item] immediately after the current item (the showlist action). */
|
||||
fun insertNext(item: T): QueueMutation<T> =
|
||||
insertAt(if (currentIndex < 0) _items.size else currentIndex + 1, item)
|
||||
|
||||
fun insertAt(index: Int, item: T): QueueMutation<T> {
|
||||
if (_items.isEmpty()) {
|
||||
_items.add(item)
|
||||
currentIndex = 0
|
||||
return QueueMutation.Insert(0, item, null)
|
||||
}
|
||||
val rawAt = index.coerceIn(0, _items.size)
|
||||
val evictAt = evictionIndex()
|
||||
if (evictAt != null) removeInternal(evictAt)
|
||||
val at = (if (evictAt != null && evictAt < rawAt) rawAt - 1 else rawAt).coerceIn(0, _items.size)
|
||||
_items.add(at, item)
|
||||
if (at <= currentIndex) currentIndex++
|
||||
return QueueMutation.Insert(at, item, evictAt)
|
||||
}
|
||||
|
||||
/** Moves the item at [from] to [to], following the current item. */
|
||||
fun move(from: Int, to: Int): QueueMutation<T> {
|
||||
if (from !in _items.indices || to !in _items.indices || from == to) return QueueMutation.None
|
||||
val currentId = _items.getOrNull(currentIndex)?.let(idOf)
|
||||
val item = _items.removeAt(from)
|
||||
_items.add(to, item)
|
||||
if (currentId != null) {
|
||||
val found = _items.indexOfFirst { idOf(it) == currentId }
|
||||
if (found >= 0) currentIndex = found
|
||||
}
|
||||
return QueueMutation.Move(from, to)
|
||||
}
|
||||
|
||||
fun removeAt(index: Int): QueueMutation<T> {
|
||||
if (index !in _items.indices) return QueueMutation.None
|
||||
removeInternal(index)
|
||||
return QueueMutation.Remove(index)
|
||||
}
|
||||
|
||||
/** Empties the queue and its history. */
|
||||
fun clear(): QueueMutation<T> {
|
||||
if (_items.isEmpty()) return QueueMutation.None
|
||||
_items.clear()
|
||||
currentIndex = -1
|
||||
_history.clear()
|
||||
historyCursor = -1
|
||||
return QueueMutation.Clear
|
||||
}
|
||||
|
||||
// ---- internals ----
|
||||
|
||||
private fun removeInternal(index: Int) {
|
||||
if (index !in _items.indices) return
|
||||
_items.removeAt(index)
|
||||
currentIndex = when {
|
||||
_items.isEmpty() -> -1
|
||||
index < currentIndex -> currentIndex - 1
|
||||
index == currentIndex -> currentIndex.coerceAtMost(_items.lastIndex)
|
||||
else -> currentIndex
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Index to evict to make room, or null when there is room. Prefers the
|
||||
* oldest already-played entry; if the current item is first, evicts the
|
||||
* tail so playback is never interrupted.
|
||||
*/
|
||||
private fun evictionIndex(): Int? {
|
||||
if (_items.size < capacity || _items.isEmpty()) return null
|
||||
return if (currentIndex > 0) 0 else _items.lastIndex
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val DEFAULT_CAPACITY = 500
|
||||
const val DEFAULT_HISTORY_CAPACITY = 100
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What a queue mutation did, in terms Media3 can replay verbatim. [evictAt] is
|
||||
* an index in the *pre-mutation* list (remove it before [Append]/[Insert]).
|
||||
*/
|
||||
sealed interface QueueMutation<out T> {
|
||||
object None : QueueMutation<Nothing>
|
||||
|
||||
data class Append<T>(val item: T, val evictAt: Int?) : QueueMutation<T>
|
||||
|
||||
data class Insert<T>(val index: Int, val item: T, val evictAt: Int?) : QueueMutation<T>
|
||||
|
||||
data class Move(val from: Int, val to: Int) : QueueMutation<Nothing>
|
||||
|
||||
data class Remove(val index: Int) : QueueMutation<Nothing>
|
||||
|
||||
object Clear : QueueMutation<Nothing>
|
||||
}
|
||||
@@ -21,10 +21,12 @@ import androidx.compose.foundation.layout.safeDrawingPadding
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -77,17 +79,35 @@ fun DoradoRoot() {
|
||||
val menus = remember { MenuController() }
|
||||
val activity = androidx.activity.compose.LocalActivity.current
|
||||
var shaded by remember { mutableStateOf(false) }
|
||||
var pinRequired by remember { mutableStateOf(false) }
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
|
||||
// "lock now" from Settings ▸ device raises the shade immediately.
|
||||
val hasPin by rememberUpdatedState(settings.hasPin)
|
||||
LaunchedEffect(Unit) {
|
||||
graph.settings.lockRequests.collect {
|
||||
pinRequired = hasPin
|
||||
shaded = true
|
||||
}
|
||||
}
|
||||
|
||||
// Wake shade (canon §5): cover the UI after the app leaves the foreground;
|
||||
// user slides the shade up to reveal the interface.
|
||||
DisposableEffect(lifecycleOwner) {
|
||||
// user slides the shade up to reveal the interface. With a PIN configured
|
||||
// the shade requires the keypad; disabling auto-lock leaves backgrounding
|
||||
// unlocked and reserves the lock for the explicit "lock now" action.
|
||||
DisposableEffect(lifecycleOwner, settings.hasPin, settings.autoLockEnabled) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
when (event) {
|
||||
// ON_STOP means the app is genuinely backgrounded; ON_PAUSE
|
||||
// also fires for permission dialogs and singleTask deep-link
|
||||
// relaunches, which used to raise the shade over the app.
|
||||
Lifecycle.Event.ON_STOP -> shaded = true
|
||||
Lifecycle.Event.ON_STOP -> {
|
||||
val pin = settings.hasPin
|
||||
if (!pin || settings.autoLockEnabled) {
|
||||
pinRequired = pin
|
||||
shaded = true
|
||||
}
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
@@ -120,7 +140,15 @@ fun DoradoRoot() {
|
||||
MiniPlayer(canvasWidth)
|
||||
}
|
||||
com.heretek.dorado_hd.ui.components.ContextMenuOverlay(menus)
|
||||
LockShade(visible = shaded, onUnlock = { shaded = false })
|
||||
LockShade(
|
||||
visible = shaded,
|
||||
onUnlock = {
|
||||
shaded = false
|
||||
pinRequired = false
|
||||
},
|
||||
pinRequired = pinRequired,
|
||||
verifyPin = { pin -> graph.settings.verifyPin(pin) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@@ -42,7 +43,9 @@ import com.heretek.dorado_hd.design.components.AlbumArt
|
||||
import com.heretek.dorado_hd.design.components.CrossbarBar
|
||||
import com.heretek.dorado_hd.design.components.EdgeCropText
|
||||
import com.heretek.dorado_hd.data.model.PinKind
|
||||
import com.heretek.dorado_hd.data.model.Playlist
|
||||
import com.heretek.dorado_hd.data.model.Track
|
||||
import com.heretek.dorado_hd.data.repo.BuiltInPlaylists
|
||||
import com.heretek.dorado_hd.design.components.KineticList
|
||||
import com.heretek.dorado_hd.design.components.firstLetterOf
|
||||
import com.heretek.dorado_hd.design.components.rememberZuneFlingBehavior
|
||||
@@ -375,60 +378,127 @@ private fun ArtistsTab() {
|
||||
)
|
||||
}
|
||||
|
||||
/** A row in the playlists pivot: a device built-in or a user playlist. */
|
||||
private sealed interface PlaylistEntry {
|
||||
val key: String
|
||||
val name: String
|
||||
|
||||
data class BuiltIn(val playlist: BuiltInPlaylists.BuiltInPlaylist) : PlaylistEntry {
|
||||
override val key: String get() = playlist.id
|
||||
override val name: String get() = playlist.title
|
||||
}
|
||||
|
||||
data class User(val playlist: Playlist) : PlaylistEntry {
|
||||
override val key: String get() = "user-${playlist.id}"
|
||||
override val name: String get() = playlist.name
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun PlaylistsTab() {
|
||||
val graph = LocalDoradoGraph.current
|
||||
val menus = LocalContextMenu.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val colors = LocalDoradoColors.current
|
||||
val playlists by graph.library.playlists().collectAsState(initial = emptyList())
|
||||
val songs by graph.library.tracks().collectAsState(initial = emptyList())
|
||||
val recentIds by graph.quickplay.recentTrackIds().collectAsState(initial = emptyList())
|
||||
var builtIns by remember { mutableStateOf<List<BuiltInPlaylists.BuiltInPlaylist>>(emptyList()) }
|
||||
|
||||
androidx.compose.foundation.layout.Box(Modifier.fillMaxSize()) {
|
||||
// Built-ins re-derive whenever the pivot opens or its inputs change —
|
||||
// hearts, persisted play counts and history — the device's "refresh on open".
|
||||
LaunchedEffect(songs, recentIds) {
|
||||
val ratings = graph.quickplay.ratings()
|
||||
val counts = graph.playCounts.counts()
|
||||
builtIns = BuiltInPlaylists.derive(songs, ratings, counts, recentIds)
|
||||
}
|
||||
|
||||
val entries = buildList<PlaylistEntry> {
|
||||
builtIns.forEach { add(PlaylistEntry.BuiltIn(it)) }
|
||||
playlists.forEach { add(PlaylistEntry.User(it)) }
|
||||
}
|
||||
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
com.heretek.dorado_hd.design.components.KineticList(
|
||||
items = playlists,
|
||||
key = { it.id },
|
||||
items = entries,
|
||||
key = { it.key },
|
||||
letter = { firstLetterOf(it.name) },
|
||||
// Leave room for the "+ new playlist" footer so it never covers a row.
|
||||
bottomPadding = 56.dp,
|
||||
rowContent = { playlist, _ ->
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(DoradoTokens.ROW_HEIGHT.dp)
|
||||
.combinedClickable(
|
||||
onClick = { graph.nav.push(DoradoDestination.PlaylistDetail(playlist.id)) },
|
||||
onLongClick = {
|
||||
menus.show(
|
||||
title = playlist.name,
|
||||
actions = listOf(
|
||||
MenuAction("pin to quickplay") {
|
||||
scope.launch {
|
||||
graph.quickplay.pin(PinKind.PLAYLIST, playlist.id, playlist.name, "${playlist.trackCount} songs", 0)
|
||||
}
|
||||
},
|
||||
MenuAction("delete playlist") {
|
||||
scope.launch { graph.library.deletePlaylist(playlist.id) }
|
||||
},
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
.padding(horizontal = DoradoTokens.EDGE.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
EdgeCropText(text = playlist.name, fontSize = DoradoTokens.TYPE_LIST.dp)
|
||||
EdgeCropText(
|
||||
text = "${playlist.trackCount} songs",
|
||||
fontSize = DoradoTokens.TYPE_LIST_SECONDARY.dp,
|
||||
color = LocalDoradoColors.current.textSecondary,
|
||||
)
|
||||
rowContent = { entry, _ ->
|
||||
when (entry) {
|
||||
is PlaylistEntry.BuiltIn -> Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(DoradoTokens.ROW_HEIGHT.dp)
|
||||
.clickable(enabled = entry.playlist.tracks.isNotEmpty()) {
|
||||
graph.controller.play(entry.playlist.tracks, 0)
|
||||
}
|
||||
.padding(horizontal = DoradoTokens.EDGE.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
// Built-ins are read-only and visually distinct:
|
||||
// accent title, "built-in" sub-label, no rename/delete.
|
||||
EdgeCropText(
|
||||
text = entry.playlist.title,
|
||||
fontSize = DoradoTokens.TYPE_LIST.dp,
|
||||
color = colors.accent,
|
||||
)
|
||||
EdgeCropText(
|
||||
text = entry.playlist.subLabel,
|
||||
fontSize = DoradoTokens.TYPE_LIST_SECONDARY.dp,
|
||||
color = colors.textSecondary,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is PlaylistEntry.User -> Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(DoradoTokens.ROW_HEIGHT.dp)
|
||||
.combinedClickable(
|
||||
onClick = { graph.nav.push(DoradoDestination.PlaylistDetail(entry.playlist.id)) },
|
||||
onLongClick = {
|
||||
menus.show(
|
||||
title = entry.playlist.name,
|
||||
actions = listOf(
|
||||
MenuAction("pin to quickplay") {
|
||||
scope.launch {
|
||||
graph.quickplay.pin(
|
||||
PinKind.PLAYLIST,
|
||||
entry.playlist.id,
|
||||
entry.playlist.name,
|
||||
"${entry.playlist.trackCount} songs",
|
||||
0,
|
||||
)
|
||||
}
|
||||
},
|
||||
MenuAction("delete playlist") {
|
||||
scope.launch { graph.library.deletePlaylist(entry.playlist.id) }
|
||||
},
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
.padding(horizontal = DoradoTokens.EDGE.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
EdgeCropText(text = entry.playlist.name, fontSize = DoradoTokens.TYPE_LIST.dp)
|
||||
EdgeCropText(
|
||||
text = "${entry.playlist.trackCount} songs",
|
||||
fontSize = DoradoTokens.TYPE_LIST_SECONDARY.dp,
|
||||
color = colors.textSecondary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
if (playlists.isEmpty()) {
|
||||
if (entries.isEmpty()) {
|
||||
EdgeCropText(
|
||||
text = "no playlists yet — create one below",
|
||||
fontSize = DoradoTokens.TYPE_LIST.dp,
|
||||
@@ -487,6 +557,7 @@ private fun SongsTab() {
|
||||
menus.show(
|
||||
title = track.title,
|
||||
actions = trackMenuActions(graph, scope, track) + listOf(
|
||||
MenuAction("insert next") { graph.controller.insertNext(track) },
|
||||
MenuAction("add to playlist") {
|
||||
menus.show(
|
||||
title = "add to playlist",
|
||||
|
||||
@@ -31,6 +31,7 @@ import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.heretek.dorado_hd.BuildConfig
|
||||
import com.heretek.dorado_hd.data.security.PinLock
|
||||
import com.heretek.dorado_hd.design.LocalDoradoColors
|
||||
import com.heretek.dorado_hd.design.Selawik
|
||||
import com.heretek.dorado_hd.design.DoradoAccent
|
||||
@@ -59,6 +60,7 @@ fun SettingsScreen(canvasWidth: androidx.compose.ui.unit.Dp) {
|
||||
var trackCount by remember { mutableStateOf<Int?>(null) }
|
||||
var updateStatus by remember { mutableStateOf<String?>(null) }
|
||||
var cloudStatus by remember { mutableStateOf<String?>(null) }
|
||||
var pinStatus by remember { mutableStateOf<String?>(null) }
|
||||
androidx.compose.runtime.LaunchedEffect(Unit) { trackCount = graph.library.trackCount() }
|
||||
|
||||
// SAF tree picker → recursive import into the library.
|
||||
@@ -209,6 +211,53 @@ fun SettingsScreen(canvasWidth: androidx.compose.ui.unit.Dp) {
|
||||
onClick = { scope.launch { graph.analysis.analyzeAll(graph.library.tracks().first()) } },
|
||||
)
|
||||
|
||||
SectionLabel("screen lock")
|
||||
SettingsRow(
|
||||
label = if (settings.hasPin) "change pin" else "set pin",
|
||||
subLabel = pinStatus ?: if (settings.hasPin) {
|
||||
"4–6 digits · stored as a salted pbkdf2 hash, never plaintext"
|
||||
} else {
|
||||
"protect the wake shade with a 4–6 digit pin"
|
||||
},
|
||||
onClick = {
|
||||
menus.showPrompt("screen lock pin", "4–6 digits") { value ->
|
||||
val pin = value.trim()
|
||||
if (!PinLock.isValid(pin)) {
|
||||
pinStatus = "pin must be 4–6 digits"
|
||||
} else {
|
||||
scope.launch {
|
||||
graph.settings.setPin(pin)
|
||||
pinStatus = "pin set"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
if (settings.hasPin) {
|
||||
SettingsToggle(
|
||||
label = "auto-lock on background",
|
||||
subLabel = "require the pin after leaving the app",
|
||||
value = settings.autoLockEnabled,
|
||||
) { enabled ->
|
||||
scope.launch { graph.settings.setAutoLock(enabled) }
|
||||
}
|
||||
SettingsRow(
|
||||
label = "lock now",
|
||||
subLabel = "show the wake shade immediately",
|
||||
onClick = { graph.settings.requestLockNow() },
|
||||
)
|
||||
SettingsRow(
|
||||
label = "clear pin",
|
||||
subLabel = "remove the screen lock",
|
||||
onClick = {
|
||||
scope.launch {
|
||||
graph.settings.clearPin()
|
||||
pinStatus = "pin cleared"
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
SectionLabel("scrobbling")
|
||||
SettingsToggle(
|
||||
label = "last.fm scrobbling",
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
package com.heretek.dorado_hd
|
||||
|
||||
import android.net.Uri
|
||||
import com.heretek.dorado_hd.data.model.Rating
|
||||
import com.heretek.dorado_hd.data.model.Track
|
||||
import com.heretek.dorado_hd.data.repo.BuiltInPlaylists
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
|
||||
/** Track C — derivable built-in playlists (`BuiltIn-*`). */
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class BuiltInPlaylistsTest {
|
||||
|
||||
private fun track(id: Long, title: String, artist: String, artistId: Long) = Track(
|
||||
mediaId = id,
|
||||
title = title,
|
||||
artist = artist,
|
||||
artistId = artistId,
|
||||
album = "album",
|
||||
albumId = 1L,
|
||||
genre = "genre",
|
||||
durationMs = 1_000L,
|
||||
dateAdded = 0L,
|
||||
trackNumber = 0,
|
||||
year = "",
|
||||
uri = Uri.parse("file:///track/$id"),
|
||||
)
|
||||
|
||||
private val library = listOf(
|
||||
track(1, "alpha", "A", 10),
|
||||
track(2, "beta", "A", 10),
|
||||
track(3, "gamma", "B", 20),
|
||||
track(4, "delta", "C", 30),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `favorite tracks are the hearts sorted by title, limited`() {
|
||||
val ratings = mapOf(1L to Rating.HEART.value, 3L to Rating.HEART.value, 2L to Rating.BROKEN.value)
|
||||
|
||||
val favorites = BuiltInPlaylists.favoriteTracks(library, ratings)
|
||||
|
||||
assertEquals(listOf(1L, 3L), favorites.map { it.mediaId })
|
||||
assertEquals(listOf(1L), BuiltInPlaylists.favoriteTracks(library, ratings, limit = 1).map { it.mediaId })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `most played artists ranks by summed plays then track plays`() {
|
||||
// A = 5+3, B = 10, C = 0 (excluded).
|
||||
val plays = mapOf(1L to 5, 2L to 3, 3L to 10, 4L to 0)
|
||||
|
||||
val ordered = BuiltInPlaylists.mostPlayedArtists(library, plays)
|
||||
|
||||
// Artist B (10) outranks A (8); A's tracks follow by play count.
|
||||
assertEquals(listOf(3L, 1L, 2L), ordered.map { it.mediaId })
|
||||
assertTrue("unplayed artists are excluded", ordered.none { it.mediaId == 4L })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `most played artists honors artist and track limits`() {
|
||||
val plays = mapOf(1L to 5, 2L to 3, 3L to 10, 4L to 0)
|
||||
|
||||
assertEquals(
|
||||
listOf(3L),
|
||||
BuiltInPlaylists.mostPlayedArtists(library, plays, artistLimit = 1).map { it.mediaId },
|
||||
)
|
||||
assertEquals(
|
||||
listOf(3L, 1L),
|
||||
BuiltInPlaylists.mostPlayedArtists(library, plays, trackLimit = 2).map { it.mediaId },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `recent tracks dedupe, skip unknown ids and stay newest first`() {
|
||||
val recentIds = listOf(99L, 2L, 2L, 1L, 5L)
|
||||
|
||||
val recent = BuiltInPlaylists.recentTracks(library, recentIds)
|
||||
|
||||
assertEquals(listOf(2L, 1L), recent.map { it.mediaId })
|
||||
assertEquals(listOf(2L), BuiltInPlaylists.recentTracks(library, recentIds, limit = 1).map { it.mediaId })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `derive omits empty built-ins and keeps device order`() {
|
||||
val ratings = mapOf(1L to Rating.HEART.value)
|
||||
val plays = mapOf(3L to 4)
|
||||
val recent = listOf(2L)
|
||||
|
||||
val derived = BuiltInPlaylists.derive(library, ratings, plays, recent)
|
||||
|
||||
assertEquals(
|
||||
listOf(
|
||||
BuiltInPlaylists.FAVORITE_TRACKS_ID,
|
||||
BuiltInPlaylists.MOST_PLAYED_ARTISTS_ID,
|
||||
BuiltInPlaylists.RECENT_TRACKS_ID,
|
||||
),
|
||||
derived.map { it.id },
|
||||
)
|
||||
assertTrue(derived.all { it.tracks.isNotEmpty() })
|
||||
|
||||
assertTrue(BuiltInPlaylists.derive(library, emptyMap(), emptyMap(), emptyList()).isEmpty())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package com.heretek.dorado_hd
|
||||
|
||||
import com.heretek.dorado_hd.data.security.PinAttemptLimiter
|
||||
import com.heretek.dorado_hd.data.security.PinLock
|
||||
import java.security.SecureRandom
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/** Track C — screen-lock PIN hashing and attempt rate limiting. */
|
||||
class PinLockTest {
|
||||
|
||||
// Low iteration count keeps the unit tests fast; production uses
|
||||
// PinLock.DEFAULT_ITERATIONS.
|
||||
private val iterations = 1_000
|
||||
|
||||
@Test
|
||||
fun `pin shape accepts 4 to 6 digits only`() {
|
||||
assertTrue(PinLock.isValid("1234"))
|
||||
assertTrue(PinLock.isValid("123456"))
|
||||
assertFalse(PinLock.isValid("123"))
|
||||
assertFalse(PinLock.isValid("1234567"))
|
||||
assertFalse(PinLock.isValid("12a4"))
|
||||
assertFalse(PinLock.isValid(""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `correct pin verifies and incorrect pin does not`() {
|
||||
val stored = PinLock.hashNew("4821", SecureRandom(), iterations)
|
||||
|
||||
assertTrue(PinLock.verify("4821", stored))
|
||||
assertFalse(PinLock.verify("1234", stored))
|
||||
assertFalse(PinLock.verify("4822", stored))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no plaintext is stored or returned`() {
|
||||
val pin = "90210"
|
||||
val stored = PinLock.hashNew(pin, SecureRandom(), iterations)
|
||||
|
||||
assertFalse("hash must not equal the raw pin", stored.hash.contentEquals(pin.toByteArray()))
|
||||
assertFalse("stored record must not contain the pin", PinLock.encode(stored.hash).contains(pin))
|
||||
assertFalse("salt must not be the pin", stored.salt.contentEquals(pin.toByteArray()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the same pin hashes differently with random salts`() {
|
||||
val a = PinLock.hashNew("1234", SecureRandom(), iterations)
|
||||
val b = PinLock.hashNew("1234", SecureRandom(), iterations)
|
||||
|
||||
assertNotEquals(a, b)
|
||||
assertFalse(a.hash.contentEquals(b.hash))
|
||||
assertTrue(PinLock.verify("1234", a))
|
||||
assertTrue(PinLock.verify("1234", b))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `iteration count travels with the credential`() {
|
||||
val stored = PinLock.hashNew("1111", SecureRandom(), iterations)
|
||||
|
||||
assertEquals(iterations, stored.iterations)
|
||||
assertTrue(PinLock.verify("1111", stored))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `limiter locks after the configured number of failures`() {
|
||||
var now = 0L
|
||||
val limiter = PinAttemptLimiter(maxAttempts = 5, cooldownMs = 30_000L, clock = { now })
|
||||
|
||||
repeat(4) { limiter.onFailure() }
|
||||
assertFalse(limiter.isLocked())
|
||||
assertEquals(4, limiter.failures)
|
||||
|
||||
limiter.onFailure()
|
||||
assertTrue(limiter.isLocked())
|
||||
assertEquals(30_000L, limiter.remainingCooldownMs())
|
||||
assertFalse(limiter.canAttempt())
|
||||
|
||||
// Additional failures during the cooldown do not extend it.
|
||||
limiter.onFailure()
|
||||
assertEquals(30_000L, limiter.remainingCooldownMs())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cooldown counts down and then resets`() {
|
||||
var now = 100_000L
|
||||
val limiter = PinAttemptLimiter(maxAttempts = 2, cooldownMs = 30_000L, clock = { now })
|
||||
|
||||
limiter.onFailure()
|
||||
limiter.onFailure()
|
||||
assertTrue(limiter.isLocked())
|
||||
assertEquals(30_000L, limiter.remainingCooldownMs())
|
||||
|
||||
now += 10_000L
|
||||
assertEquals(20_000L, limiter.remainingCooldownMs())
|
||||
assertTrue(limiter.isLocked())
|
||||
|
||||
now += 20_000L
|
||||
assertFalse(limiter.isLocked())
|
||||
assertTrue(limiter.canAttempt())
|
||||
assertEquals(0, limiter.failures)
|
||||
|
||||
limiter.onSuccess()
|
||||
assertEquals(0, limiter.failures)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package com.heretek.dorado_hd
|
||||
|
||||
import com.heretek.dorado_hd.media.QueueEngine
|
||||
import com.heretek.dorado_hd.media.QueueMutation
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/** Track C — deep queue engine (`CMediaQueueBase`/`CTrackListQueue`). */
|
||||
class QueueEngineTest {
|
||||
|
||||
private fun engine(capacity: Int = 5, historyCapacity: Int = 10) =
|
||||
QueueEngine<Int>(capacity = capacity, historyCapacity = historyCapacity) { it.toLong() }
|
||||
|
||||
@Test
|
||||
fun `append evicts the oldest played item at capacity`() {
|
||||
val q = engine(capacity = 4)
|
||||
q.reset(listOf(1, 2, 3, 4), startIndex = 2)
|
||||
|
||||
val mutation = q.append(5)
|
||||
|
||||
assertEquals(QueueMutation.Append(5, 0), mutation)
|
||||
assertEquals(listOf(2, 3, 4, 5), q.items)
|
||||
assertEquals(1, q.currentIndex)
|
||||
assertEquals(3, q.items[q.currentIndex])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `append at capacity with current first evicts the tail`() {
|
||||
val q = engine(capacity = 2)
|
||||
q.reset(listOf(1, 2), startIndex = 0)
|
||||
|
||||
q.append(3)
|
||||
|
||||
assertEquals(listOf(1, 3), q.items)
|
||||
assertEquals(0, q.currentIndex)
|
||||
assertEquals(1, q.items[q.currentIndex])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `insert next places the item after the current one`() {
|
||||
val q = engine(capacity = 5)
|
||||
q.reset(listOf(1, 2, 3), startIndex = 1)
|
||||
|
||||
val mutation = q.insertNext(9)
|
||||
|
||||
assertEquals(QueueMutation.Insert(2, 9, null), mutation)
|
||||
assertEquals(listOf(1, 2, 9, 3), q.items)
|
||||
assertEquals(1, q.currentIndex)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `insert next evicts an already-played item at capacity`() {
|
||||
val q = engine(capacity = 4)
|
||||
q.reset(listOf(1, 2, 3, 4), startIndex = 2)
|
||||
|
||||
val mutation = q.insertNext(9)
|
||||
|
||||
assertEquals(QueueMutation.Insert(2, 9, 0), mutation)
|
||||
assertEquals(listOf(2, 3, 9, 4), q.items)
|
||||
assertEquals(1, q.currentIndex)
|
||||
assertEquals(3, q.items[q.currentIndex])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `move follows the current item and rejects bad bounds`() {
|
||||
val q = engine()
|
||||
q.reset(listOf(1, 2, 3, 4), startIndex = 1)
|
||||
|
||||
val mutation = q.move(0, 2)
|
||||
|
||||
assertEquals(QueueMutation.Move(0, 2), mutation)
|
||||
assertEquals(listOf(2, 3, 1, 4), q.items)
|
||||
assertEquals(0, q.currentIndex)
|
||||
assertEquals(2, q.items[q.currentIndex])
|
||||
|
||||
assertEquals(QueueMutation.None, q.move(0, 0))
|
||||
assertEquals(QueueMutation.None, q.move(-1, 1))
|
||||
assertEquals(QueueMutation.None, q.move(0, 9))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `remove at keeps a valid current index`() {
|
||||
val q = engine()
|
||||
q.reset(listOf(1, 2, 3, 4), startIndex = 2)
|
||||
|
||||
q.removeAt(0)
|
||||
assertEquals(listOf(2, 3, 4), q.items)
|
||||
assertEquals(1, q.currentIndex)
|
||||
assertEquals(3, q.items[q.currentIndex])
|
||||
|
||||
q.removeAt(q.currentIndex)
|
||||
assertEquals(listOf(2, 4), q.items)
|
||||
assertEquals(1, q.currentIndex)
|
||||
assertEquals(4, q.items[q.currentIndex])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clear empties queue and history`() {
|
||||
val q = engine()
|
||||
q.reset(listOf(1, 2, 3), startIndex = 1)
|
||||
q.recordPlayed(1)
|
||||
q.recordPlayed(2)
|
||||
|
||||
assertEquals(QueueMutation.Clear, q.clear())
|
||||
assertTrue(q.isEmpty)
|
||||
assertEquals(-1, q.currentIndex)
|
||||
assertTrue(q.history.isEmpty())
|
||||
assertEquals(QueueMutation.None, q.clear())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reset clamps the start index and clears history`() {
|
||||
val q = engine()
|
||||
q.recordPlayed(1)
|
||||
|
||||
q.reset(listOf(1, 2, 3), startIndex = 99)
|
||||
|
||||
assertEquals(2, q.currentIndex)
|
||||
assertTrue(q.history.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `history steps back and forward`() {
|
||||
val q = engine()
|
||||
q.recordPlayed(1)
|
||||
q.recordPlayed(2)
|
||||
q.recordPlayed(3)
|
||||
|
||||
assertEquals(listOf(1L, 2L, 3L), q.history)
|
||||
assertTrue(q.canGoBack())
|
||||
assertFalse(q.canGoForward())
|
||||
|
||||
assertEquals(2L, q.back())
|
||||
assertEquals(1L, q.back())
|
||||
assertNull(q.back())
|
||||
assertEquals(2L, q.forward())
|
||||
assertEquals(3L, q.forward())
|
||||
assertNull(q.forward())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `recording after going back discards the forward branch`() {
|
||||
val q = engine()
|
||||
q.recordPlayed(1)
|
||||
q.recordPlayed(2)
|
||||
q.recordPlayed(3)
|
||||
|
||||
assertEquals(2L, q.back())
|
||||
assertEquals(1L, q.back())
|
||||
q.recordPlayed(9)
|
||||
|
||||
assertEquals(listOf(1L, 9L), q.history)
|
||||
assertEquals(1, q.historyCursor)
|
||||
assertFalse(q.canGoForward())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `consecutive replays do not duplicate history`() {
|
||||
val q = engine()
|
||||
q.recordPlayed(1)
|
||||
q.recordPlayed(1)
|
||||
q.recordPlayed(2)
|
||||
|
||||
assertEquals(listOf(1L, 2L), q.history)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `history is bounded`() {
|
||||
val q = engine(historyCapacity = 3)
|
||||
for (i in 1..4) q.recordPlayed(i)
|
||||
|
||||
assertEquals(listOf(2L, 3L, 4L), q.history)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user