diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
index be8feaa..6591e94 100644
--- a/.github/copilot-instructions.md
+++ b/.github/copilot-instructions.md
@@ -15,4 +15,5 @@ You are reviewing code for **Cygnus Player**, a minimalist, high-performance loc
2. **Forward-Only Shuffling:** When shuffling, any tracks appearing before the current playback anchor in a folder/album context must be permanently discarded from the new queue array.
3. **Smart ReplayGain:** Verify that volume multipliers dynamically apply `ALBUM_GAIN` for sequential modes and `TRACK_GAIN` for randomized modes.
4. **Jetpack Compose Only:** Enforce that all UI additions use Jetpack Compose and adhere to the high-contrast Monokai Pro color palette (prioritize Purple, Blue/Cyan, and Orange for color-blind accessibility).
-5. **Foreground Service:** Ensure any modifications to `MediaSessionService` cleanly declare the `FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK` to satisfy Android 14+ bounds.
+5. **Foreground Service:** Ensure any modifications to `MediaSessionService` cleanly declare the `FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK` to satisfy Android 16+ security boundaries.
+6. **Version Consistency:** Verify that `VERSION_NAME` and `VERSION_CODE` updates are perfectly synchronized across all 3 source files: `version.properties`, `com.festerhead.cygnusplayer.VersionInfo.kt`, and `CHANGELOG.md`.
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
new file mode 100644
index 0000000..35eb1dd
--- /dev/null
+++ b/.idea/vcs.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/AGENTS.md b/AGENTS.md
index cf16e57..9346e6b 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -9,7 +9,7 @@ You are an expert Android Engineer specializing in low-overhead audio architectu
- **No Automated Git Operations:** The AI Agent is strictly prohibited from executing `git commit` or `git push`.
- **Incremental Changes:** Work will be broken down into small, logical increments. The Agent will pause after each increment to allow the user to review, commit, and push via the Android Studio UI.
- **Branching Strategy:** All feature development and AI-assisted coding must target the `develop` branch. Pull requests from `develop` to `main` are used to trigger signed Release builds via GitHub Actions.
-- **Versioning Strategy:** The `version.properties` file in the project root is the absolute single source of truth for the app's version. Before any code is merged into `main` for a release, the AI Agent must verify and update `VERSION_NAME` and `VERSION_CODE` in this file. The GitHub Action will use this to automatically publish a public Release.
+- **Versioning Strategy:** The `version.properties` file in the project root is the source of truth for the app's version. To avoid `BuildConfig` instability in experimental environments, a manual `VersionInfo.kt` object must also be maintained in sync with this file. Before any code is merged into `main` for a release, the AI Agent must verify and update `VERSION_NAME` and `VERSION_CODE` in both `version.properties` and `app/src/main/java/com/festerhead/cygnusplayer/VersionInfo.kt`. The GitHub Action will use `version.properties` to automatically publish a public Release.
- **Static Analysis:** The Agent must run `analyze_file` (IDE Inspections) on every modified file to identify and resolve unused code, deprecations, and style issues before task completion.
- **Documentation Standards:** Every new class, interface, method, and function must include proper KDoc documentation. KDoc must clearly explain the purpose, parameters (`@param`), and return values (`@return`). For Room entities, explain the role of each column.
- **AI Commit Messages:** The user will utilize the Android Studio AI-generated commit message feature.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 344181e..fcc0d65 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -14,6 +14,18 @@ Release policy:
## [Unreleased]
+## [1.0.1] - 2026-07-25
+
+### Added
+- Minimalist Settings screen with version info and project links.
+- "Reset Music Root Folder" utility for easier storage reconfiguration.
+- Basic database diagnostics (Track and Playlist counts).
+
+### Fixed
+- Resolved Android 17.1 (API 37) binder deadlock by deferring startup permission requests.
+- Improved UI test stability with robust `waitUntil` synchronization.
+- Stabilized database stress tests for virtualized emulator environments.
+
## [1.0.0] - 2026-07-23
- Initial version
diff --git a/README.md b/README.md
index e617137..2f11954 100644
--- a/README.md
+++ b/README.md
@@ -25,7 +25,25 @@ Modern commercial streaming apps and feature-bloated players treat music like a
- **Minted Identity**: Playlists are assigned a shuffle strategy at the moment of creation, ensuring deterministic behavior for specialized collections (e.g., a dedicated "Chaos" vs. "Album" playlist).
- **No-Skip Philosophy**: Designed for the "full experience" listener—no forward/back controls. Playback is an immutable journey once started.
- **Dynamic ReplayGain**: "Smart" real-time volume normalization. Automatically applies `ALBUM_GAIN` for sequential flows and `TRACK_GAIN` for randomized tracks.
-- **Modern Android Core**: Built for Android 17 (API 37) using Jetpack Compose, Media3 (ExoPlayer), and Jetpack Glance.
+- **Persistent State Across Updates**: Per-playlist state (last played `sequence_id`, exact track position, active `ShuffleMode`, and exact shuffled order mapping) is stored in the Room database, ensuring all playback positions and custom shuffle orders are retained across application restarts and updates.
+- **Modern Android Core**: Built for Android 16 (API 36) using Jetpack Compose, Media3 (ExoPlayer), and Jetpack Glance.
+
+## 📱 Application Screenshots
+
+### Initial Setup & Storage Access
+| App Launch | Select Music Root | SAF Root Permission | Media Permission |
+| :-: | :-: | :-: | :-: |
+|
|
|
|
|
+
+### Playlist Management & Minting
+| Empty Playlist History | Mint Shuffle Mode | Active Playlist History |
+| :-: | :-: | :-: |
+|
|
|
|
+
+### Playback, Widget & Configuration
+| Minimalist Now Playing | Home Screen Widget | Settings & Diagnostics |
+| :-: | :-: | :-: |
+|
|
|
|
## 📁 Storage & Scoped Storage Compliance
@@ -41,7 +59,7 @@ This project is developed and managed using Google AI models. The architecture,
## 🛠 Tech Stack
-- **Target Platform**: Android 17 (API Level 37)
+- **Target Platform**: Android 16 (API Level 36)
- **UI Framework**: Jetpack Compose
- **Playback Engine**: `androidx.media3:media3-exoplayer` & `MediaSessionService`
- **Database**: `androidx.room` with SQLite indexing on `sequence_id` and `file_path`
@@ -96,7 +114,7 @@ Cygnus Player utilizes a custom-designed **Adaptive Icon** that reflects the cos
- [x] Folder-Sequential Logic: History-aware directory shuffling (24-folder buffer).
- [x] ReplayGain Controller: "Smart" gain switching logic (Album vs. Track).
- [x] **Playback**: Media3 Service integration with ReplayGain and Audio Focus.
- - [x] `MediaSessionService`: Foreground service with Android 17 security bounds.
+ - [x] `MediaSessionService`: Foreground service with Android 16 security bounds.
- [x] ExoPlayer Core: Gapless transition and volume normalization.
- [x] Lazy Queue Controller: Sliding window logic for $O(1)$ memory playback.
- [x] System Integration: Audio Focus and `BECOMING_NOISY` handling.
@@ -112,45 +130,44 @@ Cygnus Player utilizes a custom-designed **Adaptive Icon** that reflects the cos
- [x] Position Persistence: Per-playlist millisecond-accurate resumption.
- [x] Smart Bluetooth: Automated playback resumption upon device reconnection.
-## 🧪 Running Tests
-
-To maintain "Zero-Manual-Discovery" of bugs, Cygnus Player uses a dual-layered testing strategy. For more details, see [TESTING_STRATEGY.md](TESTING_STRATEGY.md).
-
-### 1. Unit Tests (Logic-First)
+## 🧪 High-Efficiency Workflows
-Run these for sub-second validation of parsers, mapping logic, and shuffle algorithms on the JVM.
+To maintain "Zero-Manual-Discovery" of bugs while bypassing framework-level environmental issues (like the Android 17.1 binder deadlock), use the following PowerShell aliases.
+### 1. Alias Setup
+Add these to your PowerShell `$PROFILE` for maximum productivity:
```powershell
-./gradlew test
+function ctest {
+ adb shell input keyevent 224; adb shell wm dismiss-keyguard
+ ./gradlew test connectedDebugAndroidTest
+}
+function crun {
+ adb shell input keyevent 224; adb shell wm dismiss-keyguard
+ ./gradlew installRelease
+ adb shell am start -n com.festerhead.cygnusplayer/.MainActivity
+}
+function cdebug {
+ adb shell input keyevent 224; adb shell wm dismiss-keyguard
+ ./gradlew installDebug
+ adb shell am start -n com.festerhead.cygnusplayer/.MainActivity
+}
```
-### 2. Instrumented Tests (Device Validation)
-
-Run these to verify Room database integrity and physical media extraction on a connected device or emulator.
-
+### 2. Manual Commands
+If you prefer the standard Gradle tasks, ensure the emulator is **awake and unlocked** first:
```powershell
-./gradlew connectedDebugAndroidTest
-```
+# Wake up and unlock
+adb shell input keyevent 224; adb shell wm dismiss-keyguard
-> [!TIP]
-> You can run both suites sequentially using:
->
-> ```powershell
-> ./gradlew test connectedDebugAndroidTest
-> ```
->
-> If Gradle skips tests due to UP-TO-DATE checks, you can force execution using:
->
-> ```powershell
-> ./gradlew clean test # Deletes build artifacts and runs everything fresh
-> ./gradlew test --rerun-tasks # Runs all test tasks regardless of cache
-> ```
+# Run full suite
+./gradlew test connectedDebugAndroidTest
+```
## 🚀 Deployment & CI/CD
### Local Deployment
-To build the APK and install the app on your connected device or emulator from the CLI:
+The fastest way to deploy is using the **`crun`** or **`cdebug`** aliases defined above. Alternatively, use the manual commands:
```powershell
./gradlew :app:assembleDebug
@@ -171,10 +188,22 @@ Cygnus Player utilizes GitHub Actions for continuous integration and delivery:
#### Versioning
-The single source of truth for the app's version is the `version.properties` file in the project root. Before merging to `main` to trigger a release, ensure you update this file:
+The app's version is maintained in two locations for build stability:
+1. **`version.properties`**: The primary source of truth used by Gradle and CI/CD.
+2. **`VersionInfo.kt`**: A static object in the source code used by the UI to avoid `BuildConfig` race conditions in experimental environments.
+
+Before merging to `main` to trigger a release, ensure both files are updated:
```properties
-VERSION_NAME=1.0.0
-VERSION_CODE=1
+# version.properties
+VERSION_NAME=1.0.1
+VERSION_CODE=2
+```
+```kotlin
+// VersionInfo.kt
+object VersionInfo {
+ const val VERSION_NAME = "1.0.1"
+ const val VERSION_CODE = 2
+}
```
Gradle will automatically inject these values into the APK, and the GitHub Action will parse them to name your automated Release!
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index d274142..8d65477 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -67,6 +67,7 @@ android {
buildFeatures {
compose = true
+ buildConfig = true
}
@Suppress("UnstableApiUsage")
diff --git a/app/src/androidTest/java/com/festerhead/cygnusplayer/ui/screens/NowPlayingScreenTest.kt b/app/src/androidTest/java/com/festerhead/cygnusplayer/ui/screens/NowPlayingScreenTest.kt
index 3dcdf5f..f441e28 100644
--- a/app/src/androidTest/java/com/festerhead/cygnusplayer/ui/screens/NowPlayingScreenTest.kt
+++ b/app/src/androidTest/java/com/festerhead/cygnusplayer/ui/screens/NowPlayingScreenTest.kt
@@ -28,6 +28,10 @@ class NowPlayingScreenTest {
}
}
+ composeTestRule.waitUntil(20000) {
+ composeTestRule.onAllNodes(androidx.compose.ui.test.hasText("No track playing")).fetchSemanticsNodes().isNotEmpty()
+ }
+
composeTestRule.onNodeWithText("No track playing").assertIsDisplayed()
composeTestRule.onNodeWithContentDescription("Back to Playlists").assertIsDisplayed()
composeTestRule.onNodeWithContentDescription("Play").assertIsDisplayed()
diff --git a/app/src/androidTest/java/com/festerhead/cygnusplayer/ui/screens/PlaylistNavigationTest.kt b/app/src/androidTest/java/com/festerhead/cygnusplayer/ui/screens/PlaylistNavigationTest.kt
index dbb919d..97cbd54 100644
--- a/app/src/androidTest/java/com/festerhead/cygnusplayer/ui/screens/PlaylistNavigationTest.kt
+++ b/app/src/androidTest/java/com/festerhead/cygnusplayer/ui/screens/PlaylistNavigationTest.kt
@@ -1,6 +1,5 @@
package com.festerhead.cygnusplayer.ui.screens
-import android.app.Application
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -42,8 +41,8 @@ class PlaylistNavigationTest {
)
val pickerViewModel = PlaylistPickerViewModel(
- ApplicationProvider.getApplicationContext(),
- playlistStateDao
+ ApplicationProvider.getApplicationContext(),
+ playlistStateDao,
)
// We use a state to control navigation in the test
@@ -53,9 +52,14 @@ class PlaylistNavigationTest {
composeTestRule.setContent {
CygnusPlayerTheme {
if (currentScreen == "PICKER") {
- PlaylistPickerScreen(viewModel = pickerViewModel) { path ->
- selectedPlaylistPath = path
- currentScreen = "NOW_PLAYING"
+ PlaylistPickerScreen(
+ viewModel = pickerViewModel,
+ onPlaylistSelected = { path ->
+ selectedPlaylistPath = path
+ currentScreen = "NOW_PLAYING"
+ }
+ ) {
+ currentScreen = "NOW_PLAYING" // Simplified for lambda
}
} else {
// Inject a NowPlayingViewModel that reflects the selected playlist
@@ -74,10 +78,16 @@ class PlaylistNavigationTest {
}
// 1. Select the playlist
+ composeTestRule.waitUntil(20000) {
+ composeTestRule.onAllNodes(androidx.compose.ui.test.hasText("Rush.m3u8")).fetchSemanticsNodes().isNotEmpty()
+ }
composeTestRule.onNodeWithText("Rush.m3u8").performClick()
composeTestRule.waitForIdle()
// 2. Verify navigation and playlist name display
+ composeTestRule.waitUntil(20000) {
+ composeTestRule.onAllNodes(androidx.compose.ui.test.hasText("Rush.m3u8")).fetchSemanticsNodes().size == 1
+ }
composeTestRule.onNodeWithText("Rush.m3u8").assertIsDisplayed()
// 3. Verify that the correct shuffle mode is initialized and rendered on the screen
diff --git a/app/src/androidTest/java/com/festerhead/cygnusplayer/ui/screens/PlaylistPickerScreenTest.kt b/app/src/androidTest/java/com/festerhead/cygnusplayer/ui/screens/PlaylistPickerScreenTest.kt
index 53cfced..2489820 100644
--- a/app/src/androidTest/java/com/festerhead/cygnusplayer/ui/screens/PlaylistPickerScreenTest.kt
+++ b/app/src/androidTest/java/com/festerhead/cygnusplayer/ui/screens/PlaylistPickerScreenTest.kt
@@ -1,6 +1,5 @@
package com.festerhead.cygnusplayer.ui.screens
-import android.app.Application
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
@@ -36,11 +35,11 @@ class PlaylistPickerScreenTest {
// Mock the initial history load
coEvery { playlistStateDao.getAllStates() } returns listOf(
PlaylistStateEntity("/storage/music/Rush.m3u8", 0, ShuffleMode.SEQUENTIAL, 1000L),
- PlaylistStateEntity("/storage/music/Hemispheres.m3u", 2112, ShuffleMode.TRACK_RANDOM, 2000L)
+ PlaylistStateEntity("/storage/music/Hemispheres.m3u", 2112, ShuffleMode.TRACK_RANDOM, 2000L),
)
viewModel = PlaylistPickerViewModel(
- ApplicationProvider.getApplicationContext(),
+ ApplicationProvider.getApplicationContext(),
playlistStateDao
)
}
@@ -49,10 +48,17 @@ class PlaylistPickerScreenTest {
fun testBrandingAndGuidance() {
composeTestRule.setContent {
CygnusPlayerTheme {
- PlaylistPickerScreen(viewModel = viewModel, onPlaylistSelected = {})
+ PlaylistPickerScreen(
+ viewModel = viewModel,
+ onPlaylistSelected = {}
+ ) {}
}
}
+ composeTestRule.waitUntil(20000) {
+ composeTestRule.onAllNodes(androidx.compose.ui.test.hasText("Cygnus Player")).fetchSemanticsNodes().isNotEmpty()
+ }
+
// Verify branding title
composeTestRule.onNodeWithText("Cygnus Player").assertIsDisplayed()
@@ -64,10 +70,17 @@ class PlaylistPickerScreenTest {
fun testHistoryListRendering() {
composeTestRule.setContent {
CygnusPlayerTheme {
- PlaylistPickerScreen(viewModel = viewModel, onPlaylistSelected = {})
+ PlaylistPickerScreen(
+ viewModel = viewModel,
+ onPlaylistSelected = {}
+ ) {}
}
}
+ composeTestRule.waitUntil(20000) {
+ composeTestRule.onAllNodes(androidx.compose.ui.test.hasText("Rush.m3u8")).fetchSemanticsNodes().isNotEmpty()
+ }
+
// Verify that filenames (from paths) are rendered
composeTestRule.onNodeWithText("Rush.m3u8").assertIsDisplayed()
composeTestRule.onNodeWithText("Hemispheres.m3u").assertIsDisplayed()
@@ -81,7 +94,10 @@ class PlaylistPickerScreenTest {
fun testErrorSnackbarVisibility() {
composeTestRule.setContent {
CygnusPlayerTheme {
- PlaylistPickerScreen(viewModel = viewModel, onPlaylistSelected = {})
+ PlaylistPickerScreen(
+ viewModel = viewModel,
+ onPlaylistSelected = {}
+ ) {}
}
}
diff --git a/app/src/androidTest/java/com/festerhead/cygnusplayer/ui/settings/SettingsScreenTest.kt b/app/src/androidTest/java/com/festerhead/cygnusplayer/ui/settings/SettingsScreenTest.kt
new file mode 100644
index 0000000..b7a39f9
--- /dev/null
+++ b/app/src/androidTest/java/com/festerhead/cygnusplayer/ui/settings/SettingsScreenTest.kt
@@ -0,0 +1,36 @@
+package com.festerhead.cygnusplayer.ui.settings
+
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithText
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.festerhead.cygnusplayer.ui.theme.CygnusPlayerTheme
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class SettingsScreenTest {
+
+ @get:Rule
+ val composeTestRule = createComposeRule()
+
+ @Test
+ fun testSettingsScreenRendersCorrectly() {
+ composeTestRule.setContent {
+ CygnusPlayerTheme {
+ SettingsScreen(onNavigateBack = {})
+ }
+ }
+
+ // Verify sections
+ composeTestRule.onNodeWithText("CONFIGURATION").assertIsDisplayed()
+ composeTestRule.onNodeWithText("DIAGNOSTICS").assertIsDisplayed()
+ composeTestRule.onNodeWithText("ABOUT").assertIsDisplayed()
+
+ // Verify specific items
+ composeTestRule.onNodeWithText("Reset Music Root Folder").assertIsDisplayed()
+ composeTestRule.onNodeWithText("GitHub Repository").assertIsDisplayed()
+ composeTestRule.onNodeWithText("MIT License").assertIsDisplayed()
+ }
+}
diff --git a/app/src/main/java/com/festerhead/cygnusplayer/MainActivity.kt b/app/src/main/java/com/festerhead/cygnusplayer/MainActivity.kt
index 9574b07..6c49118 100644
--- a/app/src/main/java/com/festerhead/cygnusplayer/MainActivity.kt
+++ b/app/src/main/java/com/festerhead/cygnusplayer/MainActivity.kt
@@ -1,22 +1,20 @@
package com.festerhead.cygnusplayer
-import android.Manifest
import android.os.Bundle
import androidx.activity.ComponentActivity
-import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
-import androidx.activity.result.contract.ActivityResultContracts
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.collectAsState
-import androidx.compose.runtime.getValue
+import androidx.activity.viewModels
+import androidx.compose.runtime.*
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
-import androidx.lifecycle.viewmodel.compose.viewModel
import com.festerhead.cygnusplayer.ui.screens.NowPlayingScreen
import com.festerhead.cygnusplayer.ui.screens.PlaylistPickerScreen
+import com.festerhead.cygnusplayer.ui.settings.SettingsScreen
import com.festerhead.cygnusplayer.ui.theme.CygnusPlayerTheme
import com.festerhead.cygnusplayer.ui.viewmodel.NowPlayingViewModel
+import kotlinx.coroutines.delay
+import kotlin.time.Duration.Companion.milliseconds
/**
* The main activity of the Cygnus Player application.
@@ -26,59 +24,66 @@ import com.festerhead.cygnusplayer.ui.viewmodel.NowPlayingViewModel
*/
class MainActivity : ComponentActivity() {
+ private val viewModel: PlaylistPickerViewModel by viewModels {
+ object : ViewModelProvider.Factory {
+ @Suppress("UNCHECKED_CAST")
+ override fun create(modelClass: Class): T {
+ val db = (application as CygnusApplication).database
+ return PlaylistPickerViewModel(application, db.playlistStateDao()) as T
+ }
+ }
+ }
+
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
- val db = (application as CygnusApplication).database
-
setContent {
CygnusPlayerTheme {
- val permissionLauncher = rememberLauncherForActivityResult(
- ActivityResultContracts.RequestPermission(),
- ) { /* Handle result */ }
+ var isReady by remember { mutableStateOf(value = false) }
LaunchedEffect(Unit) {
- permissionLauncher.launch(Manifest.permission.READ_MEDIA_AUDIO)
+ // Small delay to ensure system binder is stable on Android 17.1
+ delay(500.milliseconds)
+ isReady = true
}
- val viewModel: PlaylistPickerViewModel = viewModel(
- factory = object : ViewModelProvider.Factory {
- @Suppress("UNCHECKED_CAST")
- override fun create(modelClass: Class): T {
- return PlaylistPickerViewModel(application, db.playlistStateDao()) as T
- }
- },
- )
-
- val pickerUiState by viewModel.uiState.collectAsState()
+ if (isReady) {
+ val pickerUiState by viewModel.uiState.collectAsState()
+ var showSettings by remember { mutableStateOf(value = false) }
- if (pickerUiState.activePlaylistPath != null) {
- val nowPlayingViewModel: NowPlayingViewModel = viewModel()
- LaunchedEffect(pickerUiState.activePlaylistPath, pickerUiState.activeShuffleMode) {
- val path = pickerUiState.activePlaylistPath!!
- val decodedPath = try {
- android.net.Uri.decode(path)
- } catch (_: Exception) {
- path
+ if (showSettings) {
+ SettingsScreen(onNavigateBack = { showSettings = false })
+ } else if (pickerUiState.activePlaylistPath != null) {
+ val nowPlayingViewModel: NowPlayingViewModel = androidx.lifecycle.viewmodel.compose.viewModel()
+ LaunchedEffect(pickerUiState.activePlaylistPath, pickerUiState.activeShuffleMode) {
+ val path = pickerUiState.activePlaylistPath!!
+ val decodedPath = try {
+ android.net.Uri.decode(path)
+ } catch (_: Exception) {
+ path
+ }
+ val name = decodedPath.substringAfterLast("/").substringAfterLast("\\")
+ nowPlayingViewModel.initialize(
+ name,
+ pickerUiState.activeShuffleMode,
+ )
}
- val name = decodedPath.substringAfterLast("/").substringAfterLast("\\")
- nowPlayingViewModel.initialize(
- name,
- pickerUiState.activeShuffleMode,
- )
- }
- NowPlayingScreen(
- viewModel = nowPlayingViewModel,
- ) {
- viewModel.setActivePlaylist(null)
- }
- } else {
- PlaylistPickerScreen(viewModel = viewModel) { path ->
- viewModel.onPlaylistClicked(this, path) {
- // Handled by reactive state
+ NowPlayingScreen(
+ viewModel = nowPlayingViewModel,
+ ) {
+ viewModel.setActivePlaylist(null)
}
+ } else {
+ PlaylistPickerScreen(
+ viewModel = viewModel,
+ onPlaylistSelected = { path ->
+ viewModel.onPlaylistClicked(this, path) {
+ // Handled by reactive state
+ }
+ },
+ ) { showSettings = true }
}
}
}
diff --git a/app/src/main/java/com/festerhead/cygnusplayer/VersionInfo.kt b/app/src/main/java/com/festerhead/cygnusplayer/VersionInfo.kt
new file mode 100644
index 0000000..590ed19
--- /dev/null
+++ b/app/src/main/java/com/festerhead/cygnusplayer/VersionInfo.kt
@@ -0,0 +1,12 @@
+package com.festerhead.cygnusplayer
+
+/**
+ * Static version information for the Cygnus Player application.
+ *
+ * This object is used instead of the auto-generated BuildConfig class to ensure
+ * build stability across environments where the Android toolchain may be inconsistent.
+ */
+object VersionInfo {
+ const val VERSION_NAME = "1.0.1"
+ const val VERSION_CODE = 2
+}
diff --git a/app/src/main/java/com/festerhead/cygnusplayer/data/daos/PlaylistStateDao.kt b/app/src/main/java/com/festerhead/cygnusplayer/data/daos/PlaylistStateDao.kt
index 1b1bf7a..38bb8ae 100644
--- a/app/src/main/java/com/festerhead/cygnusplayer/data/daos/PlaylistStateDao.kt
+++ b/app/src/main/java/com/festerhead/cygnusplayer/data/daos/PlaylistStateDao.kt
@@ -35,4 +35,10 @@ interface PlaylistStateDao {
*/
@Query("DELETE FROM playlist_states WHERE m3u_path = :path")
suspend fun deleteState(path: String)
+
+ /**
+ * Returns the total number of saved playlists in history.
+ */
+ @Query("SELECT COUNT(*) FROM playlist_states")
+ suspend fun getPlaylistCount(): Int
}
diff --git a/app/src/main/java/com/festerhead/cygnusplayer/data/daos/TrackDao.kt b/app/src/main/java/com/festerhead/cygnusplayer/data/daos/TrackDao.kt
index bae6cd2..6d7d118 100644
--- a/app/src/main/java/com/festerhead/cygnusplayer/data/daos/TrackDao.kt
+++ b/app/src/main/java/com/festerhead/cygnusplayer/data/daos/TrackDao.kt
@@ -58,4 +58,10 @@ interface TrackDao {
*/
@Query("DELETE FROM tracks")
suspend fun deleteAll()
+
+ /**
+ * Returns the total number of unique tracks in the database.
+ */
+ @Query("SELECT COUNT(*) FROM tracks")
+ suspend fun getTrackCount(): Int
}
diff --git a/app/src/main/java/com/festerhead/cygnusplayer/ui/screens/PlaylistPickerScreen.kt b/app/src/main/java/com/festerhead/cygnusplayer/ui/screens/PlaylistPickerScreen.kt
index e94dffc..df43021 100644
--- a/app/src/main/java/com/festerhead/cygnusplayer/ui/screens/PlaylistPickerScreen.kt
+++ b/app/src/main/java/com/festerhead/cygnusplayer/ui/screens/PlaylistPickerScreen.kt
@@ -15,6 +15,7 @@ import androidx.compose.material.icons.automirrored.filled.List
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Folder
+import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
@@ -36,12 +37,18 @@ import com.festerhead.cygnusplayer.ui.theme.MonokaiGreen
@Composable
fun PlaylistPickerScreen(
viewModel: PlaylistPickerViewModel,
- onPlaylistSelected: (String) -> Unit
+ onPlaylistSelected: (String) -> Unit,
+ onSettingsClicked: () -> Unit,
) {
val uiState by viewModel.uiState.collectAsState()
val context = LocalContext.current
val snackbarHostState = remember { SnackbarHostState() }
+ // Permission Launcher moved here to prevent startup deadlock on Android 17.1
+ val permissionLauncher = rememberLauncherForActivityResult(
+ ActivityResultContracts.RequestPermission(),
+ ) { /* Permission results are handled by system; we check again on button click if needed */ }
+
// Launcher for picking the Music Root folder
val folderLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocumentTree(),
@@ -77,8 +84,7 @@ fun PlaylistPickerScreen(
MintingDialog(
fileName = cleanPendingName,
onModeSelected = { viewModel.mintPlaylist(it) },
- onDismiss = { viewModel.cancelMinting() }
- )
+ ) { viewModel.cancelMinting() }
}
Scaffold(
@@ -109,20 +115,36 @@ fun PlaylistPickerScreen(
.padding(innerPadding)
.fillMaxSize(),
) {
- Text(
- text = "Cygnus Player",
+ Row(
modifier = Modifier
+ .fillMaxWidth()
.statusBarsPadding()
.padding(start = 24.dp, end = 24.dp, top = 32.dp, bottom = 8.dp),
- color = MaterialTheme.colorScheme.primary,
- fontSize = 40.sp,
- fontWeight = FontWeight.Black,
- letterSpacing = 4.sp,
- )
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = "Cygnus Player",
+ color = MaterialTheme.colorScheme.primary,
+ fontSize = 40.sp,
+ fontWeight = FontWeight.Black,
+ letterSpacing = 4.sp,
+ )
+ IconButton(onClick = onSettingsClicked) {
+ Icon(
+ imageVector = Icons.Default.Settings,
+ contentDescription = "Settings",
+ tint = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
// Library Root Warning/Setup
if (uiState.libraryRootUri == null) {
- LibraryRootPrompt { folderLauncher.launch(null) }
+ LibraryRootPrompt {
+ permissionLauncher.launch(android.Manifest.permission.READ_MEDIA_AUDIO)
+ folderLauncher.launch(null)
+ }
}
Text(
@@ -287,18 +309,15 @@ fun MintingDialog(
MintingButton(
label = "SEQUENTIAL",
color = MaterialTheme.colorScheme.primary,
- onClick = { onModeSelected(ShuffleMode.SEQUENTIAL) },
- )
+ ) { onModeSelected(ShuffleMode.SEQUENTIAL) }
MintingButton(
label = "ALBUM SHUFFLE",
color = MaterialTheme.colorScheme.secondary,
- onClick = { onModeSelected(ShuffleMode.RANDOM_FOLDER_SEQUENTIAL) },
- )
+ ) { onModeSelected(ShuffleMode.RANDOM_FOLDER_SEQUENTIAL) }
MintingButton(
label = "CHAOS (RANDOM)",
color = MaterialTheme.colorScheme.tertiary,
- onClick = { onModeSelected(ShuffleMode.TRACK_RANDOM) },
- )
+ ) { onModeSelected(ShuffleMode.TRACK_RANDOM) }
}
},
confirmButton = {
diff --git a/app/src/main/java/com/festerhead/cygnusplayer/ui/settings/SettingsScreen.kt b/app/src/main/java/com/festerhead/cygnusplayer/ui/settings/SettingsScreen.kt
new file mode 100644
index 0000000..54a14b5
--- /dev/null
+++ b/app/src/main/java/com/festerhead/cygnusplayer/ui/settings/SettingsScreen.kt
@@ -0,0 +1,137 @@
+package com.festerhead.cygnusplayer.ui.settings
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalUriHandler
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.lifecycle.viewmodel.compose.viewModel
+import com.festerhead.cygnusplayer.VersionInfo
+
+/**
+ * Minimalist Settings screen for project information and basic configuration.
+ *
+ * @param onNavigateBack Callback to return to the previous screen.
+ * @param viewModel The [SettingsViewModel] instance.
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun SettingsScreen(
+ onNavigateBack: () -> Unit,
+ viewModel: SettingsViewModel = viewModel(),
+) {
+ val uiState by viewModel.uiState.collectAsState()
+ val uriHandler = LocalUriHandler.current
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text("Settings") },
+ navigationIcon = {
+ IconButton(onClick = onNavigateBack) {
+ Icon(
+ imageVector = Icons.AutoMirrored.Filled.ArrowBack,
+ contentDescription = "Back",
+ )
+ }
+ },
+ colors = TopAppBarDefaults.topAppBarColors(
+ containerColor = MaterialTheme.colorScheme.background,
+ titleContentColor = MaterialTheme.colorScheme.primary,
+ )
+ )
+ }
+ ) { innerPadding ->
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(MaterialTheme.colorScheme.background)
+ .padding(innerPadding)
+ .verticalScroll(rememberScrollState())
+ .padding(24.dp),
+ verticalArrangement = Arrangement.spacedBy(24.dp)
+ ) {
+ // Configuration Section
+ SettingsSection(title = "Configuration") {
+ Button(
+ onClick = { viewModel.resetRootFolder() },
+ modifier = Modifier.fillMaxWidth(),
+ colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.secondary)
+ ) {
+ Text("Reset Music Root Folder", color = MaterialTheme.colorScheme.background)
+ }
+ Text(
+ text = "Forces the app to prompt for Music folder access on next launch.",
+ fontSize = 12.sp,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(top = 8.dp)
+ )
+ }
+
+ // Diagnostics Section
+ SettingsSection(title = "Diagnostics") {
+ DiagnosticRow(label = "Total Tracks Mapped", value = uiState.totalTracks.toString())
+ DiagnosticRow(label = "Saved Playlists", value = uiState.totalPlaylists.toString())
+ }
+
+ // About Section
+ SettingsSection(title = "About") {
+ Text(
+ text = "Version: ${VersionInfo.VERSION_NAME} (${VersionInfo.VERSION_CODE})",
+ color = MaterialTheme.colorScheme.primary,
+ fontWeight = FontWeight.Bold
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ TextButton(onClick = { uriHandler.openUri("https://github.com/festerhead/cygnus-player") }) {
+ Text("GitHub Repository", color = MaterialTheme.colorScheme.secondary)
+ }
+ TextButton(onClick = { uriHandler.openUri("https://opensource.org/licenses/MIT") }) {
+ Text("MIT License", color = MaterialTheme.colorScheme.tertiary)
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun SettingsSection(title: String, content: @Composable ColumnScope.() -> Unit) {
+ Column {
+ Text(
+ text = title.uppercase(),
+ fontSize = 12.sp,
+ fontWeight = FontWeight.Black,
+ letterSpacing = 2.sp,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(bottom = 12.dp)
+ )
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f))
+ ) {
+ Column(modifier = Modifier.padding(16.dp)) {
+ content()
+ }
+ }
+ }
+}
+
+@Composable
+fun DiagnosticRow(label: String, value: String) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 4.dp),
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ Text(text = label, color = MaterialTheme.colorScheme.onSurface)
+ Text(text = value, color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.Bold)
+ }
+}
diff --git a/app/src/main/java/com/festerhead/cygnusplayer/ui/settings/SettingsViewModel.kt b/app/src/main/java/com/festerhead/cygnusplayer/ui/settings/SettingsViewModel.kt
new file mode 100644
index 0000000..68be45f
--- /dev/null
+++ b/app/src/main/java/com/festerhead/cygnusplayer/ui/settings/SettingsViewModel.kt
@@ -0,0 +1,59 @@
+package com.festerhead.cygnusplayer.ui.settings
+
+import android.app.Application
+import android.content.Context
+import androidx.core.content.edit
+import androidx.lifecycle.AndroidViewModel
+import androidx.lifecycle.viewModelScope
+import com.festerhead.cygnusplayer.CygnusApplication
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+
+/**
+ * Diagnostic data for the Settings screen.
+ *
+ * @property totalTracks Total unique tracks indexed in the database.
+ * @property totalPlaylists Total number of playlists in history.
+ */
+data class SettingsUiState(
+ val totalTracks: Int = 0,
+ val totalPlaylists: Int = 0,
+)
+
+/**
+ * ViewModel for the Settings screen.
+ * Handles configuration resets and diagnostic data retrieval.
+ */
+class SettingsViewModel(application: Application) : AndroidViewModel(application) {
+
+ private val _uiState = MutableStateFlow(SettingsUiState())
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ init {
+ loadDiagnostics()
+ }
+
+ /**
+ * Refreshes the database diagnostic counts.
+ */
+ fun loadDiagnostics() {
+ viewModelScope.launch {
+ val app = getApplication()
+ val tracks = app.database.trackDao().getTrackCount()
+ val playlists = app.database.playlistStateDao().getPlaylistCount()
+ _uiState.update { it.copy(totalTracks = tracks, totalPlaylists = playlists) }
+ }
+ }
+
+ /**
+ * Clears the library root URI from shared preferences, forcing a re-link.
+ */
+ fun resetRootFolder() {
+ val app = getApplication()
+ val prefs = app.getSharedPreferences("cygnus_prefs", Context.MODE_PRIVATE)
+ prefs.edit { remove("library_root") }
+ }
+}
diff --git a/doc/auto_integration_strategy.md b/doc/auto_integration_strategy.md
deleted file mode 100644
index 76c299a..0000000
--- a/doc/auto_integration_strategy.md
+++ /dev/null
@@ -1,18 +0,0 @@
-# Architectural Strategy: Android Auto Integration
-
-## Purpose
-To provide a safety-compliant, minimalist "hands-free" interface for car head units while strictly adhering to our minimalist UI philosophy.
-
-## Strategy: Minimalist `MediaLibraryService`
-Instead of a full-featured library browser, we will implement a "Thin-Interface" `MediaLibraryService`:
-
-1. **Root Node**: Return a single virtual root node labeled "Playlists".
-2. **Children**: Map the current playlist history into the library hierarchy so the car head unit can navigate between your active M3U sessions.
-3. **No-Skip Compliance**: The Android Auto interface will honor the standard `MediaSession` transport controls. We will implement them to match our minimalist philosophy:
- * **Play/Pause**: Supported.
- * **Skip Next/Previous**: Explicitly **Disabled** or mapped to "Play Next Folder/Playlist" to respect the "Forward-Only" shuffle principle.
-4. **UI Minimalization**: We will provide basic track information (`title`, `artist`) to the car display, ensuring that the driver sees exactly what is on the phone screen without adding extra metadata or browser bloat.
-
-## Implementation Requirements
-- **Manifest**: Must include `` for `androidx.media3.session.MediaLibraryService`.
-- **ExoPlayer Integration**: The service must share the same `ExoPlayer` instance used by our `MediaSessionService` to ensure playback state consistency between the car head unit and the phone.
diff --git a/docs/screenshots/01 - app installed.png b/docs/screenshots/01 - app installed.png
new file mode 100644
index 0000000..7855dee
Binary files /dev/null and b/docs/screenshots/01 - app installed.png differ
diff --git a/docs/screenshots/02 - select root folder.png b/docs/screenshots/02 - select root folder.png
new file mode 100644
index 0000000..2537f4d
Binary files /dev/null and b/docs/screenshots/02 - select root folder.png differ
diff --git a/docs/screenshots/03 - root folder access.png b/docs/screenshots/03 - root folder access.png
new file mode 100644
index 0000000..9bbe798
Binary files /dev/null and b/docs/screenshots/03 - root folder access.png differ
diff --git a/docs/screenshots/04 - app permissions.png b/docs/screenshots/04 - app permissions.png
new file mode 100644
index 0000000..ed3091c
Binary files /dev/null and b/docs/screenshots/04 - app permissions.png differ
diff --git a/docs/screenshots/05 - no playlists loaded.png b/docs/screenshots/05 - no playlists loaded.png
new file mode 100644
index 0000000..4b11b17
Binary files /dev/null and b/docs/screenshots/05 - no playlists loaded.png differ
diff --git a/docs/screenshots/06 - select shuffle mode.png b/docs/screenshots/06 - select shuffle mode.png
new file mode 100644
index 0000000..0868991
Binary files /dev/null and b/docs/screenshots/06 - select shuffle mode.png differ
diff --git a/docs/screenshots/07 - one of each mode loaded.png b/docs/screenshots/07 - one of each mode loaded.png
new file mode 100644
index 0000000..671d289
Binary files /dev/null and b/docs/screenshots/07 - one of each mode loaded.png differ
diff --git a/docs/screenshots/08 - now playing.png b/docs/screenshots/08 - now playing.png
new file mode 100644
index 0000000..be52a8d
Binary files /dev/null and b/docs/screenshots/08 - now playing.png differ
diff --git a/docs/screenshots/09 - widget.png b/docs/screenshots/09 - widget.png
new file mode 100644
index 0000000..a60a5ea
Binary files /dev/null and b/docs/screenshots/09 - widget.png differ
diff --git a/docs/screenshots/10 - configuration.png b/docs/screenshots/10 - configuration.png
new file mode 100644
index 0000000..056ac33
Binary files /dev/null and b/docs/screenshots/10 - configuration.png differ
diff --git a/gradle.properties b/gradle.properties
index 86d4b81..b3a8a0d 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -1,8 +1,7 @@
# --- Gradle Configuration ---
# Specifies the JVM arguments used for the Gradle Daemon.
# -Xmx2g: Increases the maximum heap size to 2GB.
-# -Dcom.android.tools.r8.apiLevelOverride=37: Forces R8 and D8 to optimize for API 37.
-org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dcom.android.tools.r8.apiLevelOverride=37 -Dcom.android.tools.d8.apiLevelOverride=37
+org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=512m -XX:+HeapDumpOnOutOfMemoryError
# Enables the Gradle Configuration Cache, which significantly speeds up builds by reusing the results of the configuration phase.
org.gradle.configuration-cache=true
diff --git a/gradle/gradle-daemon-jvm.properties b/gradle/gradle-daemon-jvm.properties
index 6c1139e..418e7d1 100644
--- a/gradle/gradle-daemon-jvm.properties
+++ b/gradle/gradle-daemon-jvm.properties
@@ -1,12 +1,12 @@
#This file is generated by updateDaemonJvm
-toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect
-toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect
-toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect
-toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect
-toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/73bcfb608d1fde9fb62e462f834a3299/redirect
-toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/846ee0d876d26a26f37aa1ce8de73224/redirect
-toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect
-toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect
-toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/9482ddec596298c84656d31d16652665/redirect
-toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/39701d92e1756bb2f141eb67cd4c660e/redirect
+toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/bc7c5a81f3d680de7b32b5671550e6fa/redirect
+toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/06a33bd735fe2a3eaad523052759de91/redirect
+toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/bc7c5a81f3d680de7b32b5671550e6fa/redirect
+toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/06a33bd735fe2a3eaad523052759de91/redirect
+toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/f257be9f04bfdf169051808541767806/redirect
+toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/1dcbacacca32618bd21ec5465779ade1/redirect
+toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/bc7c5a81f3d680de7b32b5671550e6fa/redirect
+toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/06a33bd735fe2a3eaad523052759de91/redirect
+toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/476d3c08f4989328dee56d22e202d98d/redirect
+toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/5a88b04b5e582b332d2e6bc12b45f1b9/redirect
toolchainVersion=21
diff --git a/version.properties b/version.properties
index 478eaea..65b5a9e 100644
--- a/version.properties
+++ b/version.properties
@@ -1,2 +1,2 @@
-VERSION_NAME=1.0.0
-VERSION_CODE=1
+VERSION_NAME=1.0.1
+VERSION_CODE=2