Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
bba6e45
chore(build): unify all Android modules on JVM 17
PylotLight Sep 27, 2026
337d578
feat(app): predictive back, per-app locale config, drop dead legacy s…
PylotLight Sep 27, 2026
3389a46
chore(k8s): remote-cluster one-shot builder (no docker/colima needed)
PylotLight Sep 27, 2026
d085f6f
fix(k8s): provision JDK 17 + git in remote builder, forward version p…
PylotLight Sep 27, 2026
82432e0
chore(k8s): ignore generated render/cache dirs
PylotLight Sep 27, 2026
2addc5e
chore(build): JDK 21 toolchain, minSdk 26, wrapper/KSP alignment
PylotLight Sep 27, 2026
d8851fc
feat(scanner): incremental TagLib rescan skips unchanged files
PylotLight Sep 27, 2026
81de6dc
feat(scanner): incremental rescan, observer, broadcast trigger, worke…
PylotLight Sep 27, 2026
6e75df9
feat(theme): nest MaterialExpressiveTheme in ArtworkTheme, document d…
PylotLight Sep 27, 2026
296ec83
feat(theme): catalogued edit-tabs sheet (S2Sheet + ReorderSwitchSetting)
PylotLight Sep 27, 2026
97794ba
feat(theme): SearchScreen on the full S2SearchBar
PylotLight Sep 27, 2026
e8012ce
feat(theme): S2Dialog for the playlist and server sign-in dialogs
PylotLight Sep 27, 2026
5b38d39
feat(theme): expressive indicators for the loading views
PylotLight Sep 27, 2026
7229c64
fix(k8s): fail-fast job polling with error tail
PylotLight Sep 27, 2026
2fa8e72
Merge branch 'feat/jdk21-minsdk-build' into feat/modernisation-deps-c…
PylotLight Sep 27, 2026
eb61b42
Merge branch 'feat/incremental-scanner-autosync' into feat/modernisat…
PylotLight Sep 27, 2026
0b3ff9a
Merge branch 'feat/m3-expressive-consolidation' into feat/modernisati…
PylotLight Sep 27, 2026
5787114
chore(k8s): raise build resources after OOM at dex merge
PylotLight Sep 27, 2026
245f1f9
fix(theme): opt in EditTabsSheet to expressive APIs
PylotLight Sep 27, 2026
55605dc
fix(theme): remove stray brace in ServerSignInBody
PylotLight Sep 27, 2026
0ac25e0
fix(theme): restore ExperimentalMaterial3Api opt-in on SongActionsHost
PylotLight Sep 27, 2026
cbfc51e
fix(k8s): fetch APKs via seed pod (temurin has no tar)
PylotLight Sep 27, 2026
c308db9
feat(backup): library JSON export/import with stats-preserving restore
PylotLight Sep 28, 2026
dfbb235
fix(backup): relaxed backup-manager mock in settings tests
PylotLight Sep 28, 2026
d0b08ed
fix(backup): emulated-storage relative paths, instant merge recursion…
PylotLight Sep 28, 2026
37ddaef
fix(backup): exclusive alternation in storage-prefix strip
PylotLight Sep 28, 2026
995410b
fix(k8s): never let artifact-less fetches clobber build outputs
PylotLight Sep 28, 2026
20e4920
fix(backup): snapshot-overwrite restore resets playcounts to the backup
PylotLight Sep 29, 2026
4de8b22
feat(ui): compact mode, flat placeholders, settings tab, shelf play, …
PylotLight Sep 29, 2026
833c85a
feat(ui): push classic mode to legacy density, relabel toggle
PylotLight Sep 29, 2026
5f2fa8e
feat(ui): drop settings gears, restore old-home shortcuts and badges
PylotLight Sep 29, 2026
9b3941d
feat(ui): classic home rhythm, no top bar
PylotLight Sep 29, 2026
2044398
build: shared debug keystore so dev APKs install over each other
PylotLight Sep 29, 2026
083474a
feat(ui): Shuttle brand header, colorful shortcuts, fuller shelves
PylotLight Sep 29, 2026
ca5634e
feat(ui): bigger brand row, classic filled shortcut glyphs
PylotLight Sep 29, 2026
bc0d6da
feat(ui): classic-only home restoration, build identity in About
PylotLight Sep 29, 2026
2bd8912
feat(ui): classic shortcuts use the restored legacy glyphs
PylotLight Sep 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 28 additions & 5 deletions android/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ android {
defaultConfig {
applicationId = "com.simplecityapps.shuttle"
// 24: Compose 1.13, which material3 1.5.0-alpha29 brings in, no longer supports API 23.
minSdk = 24
minSdk = 26
targetSdk = 36
versionName = versionName()
versionCode = versionCode()
Expand All @@ -45,9 +45,22 @@ android {
// Last.fm scrobbling (#503): the key and secret ship in the APK (owner decision, 2026-09-27).
buildConfigField("String", "LASTFM_API_KEY", "\"${secret("LASTFM_API_KEY", "LASTFM_API_KEY").orEmpty()}\"")
buildConfigField("String", "LASTFM_SHARED_SECRET", "\"${secret("LASTFM_SHARED_SECRET", "LASTFM_SHARED_SECRET").orEmpty()}\"")

// Short commit the APK was built from, shown in Settings > About so dev builds
// (which share one version name) are told apart. Forwarded by k8s/scripts/build-remote.sh.
buildConfigField("String", "GIT_SHA", "\"${findProperty("gitSha")?.toString()?.takeIf { it.isNotBlank() } ?: "unknown"}\"")
}

signingConfigs {
// A checked-in debug key shared by every builder (local or cluster): without it each
// machine mints its own ~/.android key and new dev APKs refuse to install over old ones.
// Debug keys are not secrets; the release key stays out of the repo.
named("debug") {
storeFile = file("./debug.keystore")
storePassword = "android"
keyAlias = "androiddebugkey"
keyPassword = "android"
}
create("release") {
if (isCiBuild() && isReleaseBuild()) {
val keystore = file("./keystore.ks")
Expand All @@ -64,6 +77,7 @@ android {

buildTypes {
getByName("debug") {
signingConfig = signingConfigs.getByName("debug")
applicationIdSuffix = ".dev"
if (isCiBuild()) {
// We want proguard enabled on CI, so our instrumented tests run on obfuscated code
Expand All @@ -80,7 +94,16 @@ android {
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
signingConfig = signingConfigs.getByName("release")
// Play releases sign with the upload key (CI). Everywhere else the debug key
// signs release too, under the .dev id, so `assembleRelease` yields an
// installable, minified dev build instead of failing on a missing keystore.
// Same package and key as dev debug: the two swap in place, data intact.
if (isCiBuild() && isReleaseBuild()) {
signingConfig = signingConfigs.getByName("release")
} else {
signingConfig = signingConfigs.getByName("debug")
applicationIdSuffix = ".dev"
}
}
}

Expand All @@ -91,8 +114,8 @@ android {
}

compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
isCoreLibraryDesugaringEnabled = true
}

Expand Down Expand Up @@ -292,7 +315,7 @@ android {

kotlin {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_17)
jvmTarget.set(JvmTarget.JVM_21)
freeCompilerArgs.addAll(
"-Xopt-in=kotlin.RequiresOptIn",
"-Xopt-in=kotlin.time.ExperimentalTime"
Expand Down
Binary file added android/app/debug.keystore
Binary file not shown.
15 changes: 14 additions & 1 deletion android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,11 @@
android:fullBackupContent="@xml/backup_descriptor"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:requestLegacyExternalStorage="true"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme.DayNight"
android:enableOnBackInvokedCallback="true"
android:localeConfig="@xml/locales_config"
tools:ignore="GoogleAppIndexingWarning">

<activity
Expand Down Expand Up @@ -91,6 +92,18 @@

<!-- Widget -->

<!-- Automation apps and file-sync tools trigger a rescan with
`adb shell am broadcast -a com.simplecityapps.shuttle.action.RESCAN_LIBRARY`.
Deliberately unguarded (unlike the debug import receiver): third-party apps fire it too.
Repeat triggers within a minute are ignored. -->
<receiver
android:name=".scan.LibraryScanReceiver"
android:exported="true">
<intent-filter>
<action android:name="com.simplecityapps.shuttle.action.RESCAN_LIBRARY" />
</intent-filter>
</receiver>

<receiver
android:name=".ui.widgets.WidgetProvider41"
android:exported="true"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import com.simplecityapps.mediaprovider.settings.LibrarySettings
import com.simplecityapps.mediaprovider.worker.MediaImportWorker
import com.simplecityapps.shuttle.di.ApplicationContext
import com.simplecityapps.shuttle.sources.DefaultMediaSources
import com.simplecityapps.shuttle.sources.MediaLibraryObserver
import com.simplecityapps.shuttle.ui.screens.sources.MusicPermission
import dev.zacsweers.metro.Inject

Expand All @@ -14,11 +15,13 @@ class MediaProviderInitializer
constructor(
@ApplicationContext private val context: Context,
private val librarySettings: LibrarySettings,
private val mediaSources: DefaultMediaSources
private val mediaSources: DefaultMediaSources,
private val libraryObserver: MediaLibraryObserver
) : AppInitializer {
override fun init(application: Application) {
mediaSources.attachEnabled()
mediaSources.scanIfNeverScanned(MusicPermission.isGranted(context))
libraryObserver.startIfReady()

MediaImportWorker.updateWork(
context = context,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package com.simplecityapps.shuttle.scan

import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.os.SystemClock
import com.simplecityapps.mediaprovider.MediaImporter
import com.simplecityapps.shuttle.di.appGraph
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesTo
import dev.zacsweers.metro.Inject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch

/**
* Release-build rescan trigger for automation apps and file-sync tools (Tasker, FolderSync, ...):
*
* `adb shell am broadcast -a com.simplecityapps.shuttle.action.RESCAN_LIBRARY`
*
* The debug [com.simplecityapps.shuttle.debug.DebugMediaImportReceiver] does the same thing, but it lives in the
* debug source set behind the DUMP permission so only adb can fire it. This receiver is deliberately unguarded, so
* third-party apps can fire it too. Rapid repeat triggers are debounced [DEBOUNCE_MS], and [MediaImporter.import]
* coalesces with an import already running through its own importLock.
*
* Launched on Main, like the onboarding scan's `appCoroutineScope` call: [MediaImporter.import] moves its own work
* to IO but notifies its listeners on the caller's thread, and an IO caller crashes Settings > Media's listener if
* the import finishes while that screen is open (#386).
*/
class LibraryScanReceiver : BroadcastReceiver() {
@ContributesTo(AppScope::class)
interface Injector {
fun inject(receiver: LibraryScanReceiver)
}

@Inject
lateinit var mediaImporter: MediaImporter

override fun onReceive(
context: Context,
intent: Intent
) {
if (intent.action != ACTION_RESCAN_LIBRARY) return
if (!debounce()) return
context.appGraph<Injector>().inject(this)
val pendingResult = goAsync()
CoroutineScope(Dispatchers.Main).launch {
try {
mediaImporter.import()
} finally {
pendingResult.finish()
}
}
}

companion object {
const val ACTION_RESCAN_LIBRARY = "com.simplecityapps.shuttle.action.RESCAN_LIBRARY"

const val DEBOUNCE_MS = 60_000L

@Volatile
private var lastTriggerUptimeMs = 0L

internal fun debounce(nowUptimeMs: Long = SystemClock.uptimeMillis()): Boolean {
synchronized(this) {
if (nowUptimeMs - lastTriggerUptimeMs < DEBOUNCE_MS) return false
lastTriggerUptimeMs = nowUptimeMs
return true
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package com.simplecityapps.shuttle.sources

import android.content.Context
import android.database.ContentObserver
import android.net.Uri
import android.os.Handler
import android.os.Looper
import android.provider.MediaStore
import com.simplecityapps.mediaprovider.MediaImporter
import com.simplecityapps.shuttle.di.AppCoroutineScope
import com.simplecityapps.shuttle.di.ApplicationContext
import com.simplecityapps.shuttle.ui.screens.sources.MediaSources
import com.simplecityapps.shuttle.ui.screens.sources.MusicPermission
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.SingleIn
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import timber.log.Timber

/**
* Re-scans the library when MediaStore's audio changes (new downloads, tag edits from other apps, deletions).
*
* Registered once at startup by [com.simplecityapps.shuttle.appinitializers.MediaProviderInitializer], only when the
* music permission is held and an import has already finished: the first scan comes from the onboarding and
* permission-grant paths instead. Changes are debounced [DEBOUNCE_MS] so a burst of writes (an album syncing track
* by track) imports once. [MediaImporter.import] coalesces overlapping requests through its own importLock, so a
* change that lands mid-import just runs one more pass.
*
* MediaStore only covers indexed files: extra SAF trees have no observable URI (see TaglibMediaProvider's
* findExtraDocuments), so those folders still need a manual or scheduled scan.
*/
@SingleIn(AppScope::class)
class MediaLibraryObserver @Inject constructor(
@ApplicationContext private val context: Context,
private val mediaImporter: MediaImporter,
private val mediaSources: MediaSources,
@AppCoroutineScope private val appCoroutineScope: CoroutineScope
) {
private var debounceJob: Job? = null

private val observer =
object : ContentObserver(Handler(Looper.getMainLooper())) {
override fun onChange(selfChange: Boolean) = onMediaChanged()

override fun onChange(
selfChange: Boolean,
uri: Uri?
) = onMediaChanged()
}

fun startIfReady() {
if (!MusicPermission.isGranted(context) || !mediaSources.hasScanned) return
context.contentResolver.registerContentObserver(
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
true,
observer
)
}

private fun onMediaChanged() {
debounceJob?.cancel()
// The app scope runs on Main, like the onboarding scan: MediaImporter notifies its listeners on the
// caller's thread, and an IO caller crashes Settings > Media's listener if the import finishes while
// that screen is open (#386).
debounceJob =
appCoroutineScope.launch {
delay(DEBOUNCE_MS)
Timber.i("MediaStore changed, re-scanning the library")
mediaImporter.import()
}
}

companion object {
const val DEBOUNCE_MS = 20_000L
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,10 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.LoadingIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
Expand All @@ -24,6 +25,7 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.simplecityapps.shuttle.R

@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun LoadingStatusIndicator(
state: CircularLoadingState,
Expand All @@ -41,7 +43,7 @@ fun LoadingStatusIndicator(
) {
when (state) {
is CircularLoadingState.Loading -> {
CircularProgressIndicator(
LoadingIndicator(
color = progressColor,
modifier = Modifier
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,11 @@ import com.simplecityapps.shuttle.ui.screens.settings.SettingsDestinationRoute
import com.simplecityapps.shuttle.ui.screens.settings.WhatsNewRoute
import com.simplecityapps.shuttle.ui.screens.settings.model.SettingsDestination
import com.simplecityapps.shuttle.ui.screens.sources.ServerTypePickerRoute
import com.simplecityapps.shuttle.model.SmartPlaylistId
import com.simplecityapps.shuttle.ui.screens.library.SmartPlaylistRoute
import com.simplecityapps.shuttle.ui.shell.AppNavigator
import com.simplecityapps.shuttle.ui.shell.HomeRoute
import com.simplecityapps.shuttle.ui.shell.LocalShellSnackbarHostState
import com.simplecityapps.shuttle.ui.shell.SettingsRoute
import dev.zacsweers.metrox.viewmodel.metroViewModel

fun EntryProviderScope<NavKey>.homeEntries(navigator: AppNavigator) {
Expand Down Expand Up @@ -69,8 +70,10 @@ private fun HomeDestination(
HomeScreen(
uiState = uiState,
callbacks = HomeCallbacks(
onOpenSettings = { onOpen(SettingsRoute) },
onShuffleAll = { viewModel.shuffleAll()?.let(actions::dispatch) },
onOpenHistory = { onOpen(SmartPlaylistRoute(SmartPlaylistId.History.id)) },
onOpenRecentlyAdded = { onOpen(SmartPlaylistRoute(SmartPlaylistId.RecentlyAdded.id)) },
onOpenFavourites = { onOpen(SmartPlaylistRoute(SmartPlaylistId.Favourites.id)) },
onTogglePlayback = viewModel::onTogglePlayback,
onShuffleQueue = { viewModel.shuffleQueue()?.let(actions::dispatch) },
onOpenWhatsNew = {
Expand All @@ -80,6 +83,8 @@ private fun HomeDestination(
onDismissWhatsNew = viewModel::onWhatsNewHandled,
onAlbumClick = { onOpen(it.route) },
onArtistClick = { onOpen(it.route) },
onPlayAlbums = { actions.dispatch(viewModel.playAlbums(it)) },
onPlayArtists = { actions.dispatch(viewModel.playArtists(it)) },
onShowActions = actions::showActions,
),
emptyContent = (emptyState as? LibraryAvailability.Empty)?.let { empty ->
Expand Down
Loading