Add Navidrome login and album grid

- Login screen (server URL, username, password) that verifies against the
  server with an OpenSubsonic ping before saving.
- Credentials stored encrypted with an Android Keystore AES-GCM key in
  DataStore, excluded from backups. Subsonic token auth (salted md5), so the
  password is never sent.
- Album grid with cover art (Coil), A-Z, paged 100 at a time, pull to
  refresh, sign out.
- Layered packages (model, data/auth, data/subsonic, data/library, ui) with
  a manual AppContainer, ready for Media3, Android Auto and the brain service.
- Kotlin 2.4.10 and current AndroidX/OkHttp/Retrofit; fix stray "cla" typo
  in the template MainActivity.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
TySmi 2026-10-11 18:50:38 -04:00
commit 99b57f0162
68 changed files with 2420 additions and 0 deletions

6
.gitattributes vendored Normal file
View file

@ -0,0 +1,6 @@
* text=auto
gradlew text eol=lf
*.bat text eol=crlf
*.jar binary
*.webp binary
*.png binary

17
.gitignore vendored Normal file
View file

@ -0,0 +1,17 @@
*.iml
.gradle
/local.properties
/.idea/caches
/.idea/libraries
/.idea/modules.xml
/.idea/workspace.xml
/.idea/navEditor.xml
/.idea/assetWizardSettings.xml
.DS_Store
/build
/captures
.externalNativeBuild
.cxx
local.properties
/.kotlin
/.idea/deploymentTargetSelector.xml

3
.idea/.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
# Default ignored files
/shelf/
/workspace.xml

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AndroidProjectSystem">
<option name="providerId" value="com.android.tools.idea.GradleProjectSystem" />
</component>
</project>

6
.idea/compiler.xml Normal file
View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<bytecodeTargetLevel target="25" />
</component>
</project>

18
.idea/gradle.xml Normal file
View file

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GradleMigrationSettings" migrationVersion="1" />
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>
<option name="testRunner" value="CHOOSE_PER_TEST" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$" />
<option value="$PROJECT_DIR$/app" />
</set>
</option>
</GradleProjectSettings>
</option>
</component>
</project>

View file

@ -0,0 +1,61 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="ComposePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="ComposePreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="ComposePreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="ComposePreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewAnnotationInFunctionWithParameters" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewApiLevelMustBeValid" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewDeviceShouldUseNewSpec" enabled="true" level="WEAK WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewFontScaleMustBeGreaterThanZero" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewMultipleParameterProviders" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewParameterProviderOnFirstParameter" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewPickerAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
</profile>
</component>

10
.idea/misc.xml Normal file
View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_25" default="true" project-jdk-name="jbr-25" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" />
</component>
<component name="ProjectType">
<option name="id" value="Android" />
</component>
</project>

View file

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="RunConfigurationProducerService">
<option name="ignoredProducers">
<set>
<option value="com.intellij.execution.junit.AbstractAllInDirectoryConfigurationProducer" />
<option value="com.intellij.execution.junit.AllInPackageConfigurationProducer" />
<option value="com.intellij.execution.junit.PatternConfigurationProducer" />
<option value="com.intellij.execution.junit.TestInClassConfigurationProducer" />
<option value="com.intellij.execution.junit.UniqueIdConfigurationProducer" />
<option value="com.intellij.execution.junit.testDiscovery.JUnitTestDiscoveryConfigurationProducer" />
<option value="org.jetbrains.kotlin.idea.junit.KotlinJUnitRunConfigurationProducer" />
<option value="org.jetbrains.kotlin.idea.junit.KotlinPatternConfigurationProducer" />
</set>
</option>
</component>
</project>

1
app/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

70
app/build.gradle.kts Normal file
View file

@ -0,0 +1,70 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.kotlin.serialization)
}
android {
namespace = "com.tysmi.syncwave"
compileSdk {
version = release(37)
}
defaultConfig {
applicationId = "com.tysmi.syncwave"
minSdk = 28
targetSdk = 37
versionCode = 1
versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
optimization {
enable = true
packageScope = setOf("androidx.**", "kotlin.**", "kotlinx.**")
}
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
buildFeatures {
compose = true
}
}
dependencies {
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.graphics)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.datastore.preferences)
implementation(libs.retrofit)
implementation(libs.retrofit.kotlinx.serialization)
implementation(libs.okhttp)
implementation(libs.kotlinx.serialization.json)
implementation(libs.coil.compose)
implementation(libs.coil.network.okhttp)
testImplementation(libs.junit)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.okhttp.mockwebserver)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(libs.androidx.junit)
debugImplementation(libs.androidx.compose.ui.test.manifest)
debugImplementation(libs.androidx.compose.ui.tooling)
}

View file

@ -0,0 +1,24 @@
package com.tysmi.syncwave
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.Assert.*
/**
* Instrumented test, which will execute on an Android device.
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest {
@Test
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("com.tysmi.syncwave", appContext.packageName)
}
}

View file

@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:name=".SyncWaveApplication"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:networkSecurityConfig="@xml/network_security_config"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.SyncWave">
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name"
android:theme="@style/Theme.SyncWave"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View file

@ -0,0 +1,47 @@
package com.tysmi.syncwave
import android.content.Context
import com.tysmi.syncwave.data.auth.CredentialStore
import com.tysmi.syncwave.data.auth.SessionManager
import com.tysmi.syncwave.data.library.LibraryRepository
import com.tysmi.syncwave.data.subsonic.SubsonicApiFactory
import com.tysmi.syncwave.data.subsonic.SubsonicAuthInterceptor
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.serialization.json.Json
import okhttp3.OkHttpClient
import java.util.concurrent.TimeUnit
/**
* Creates the app's long-lived objects once and wires them together (manual dependency
* injection). Screens, and later the Media3 playback service, get what they need from here.
*/
class AppContainer(context: Context) {
private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
private val json = Json {
ignoreUnknownKeys = true
coerceInputValues = true
}
private val baseHttpClient = OkHttpClient.Builder()
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build()
private val apiFactory = SubsonicApiFactory(baseHttpClient, json)
val sessionManager = SessionManager(CredentialStore(context), apiFactory, appScope)
/**
* HTTP client that signs requests to the logged-in server. Used for cover art now and
* will be used for audio streaming by Media3 later.
*/
val authenticatedHttpClient: OkHttpClient = baseHttpClient.newBuilder()
.addInterceptor(SubsonicAuthInterceptor { sessionManager.currentCredentials })
.build()
val libraryRepository = LibraryRepository(sessionManager)
}

View file

@ -0,0 +1,21 @@
package com.tysmi.syncwave
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import com.tysmi.syncwave.ui.navigation.SyncWaveRoot
import com.tysmi.syncwave.ui.theme.SyncWaveTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
val container = (application as SyncWaveApplication).container
setContent {
SyncWaveTheme {
SyncWaveRoot(container.sessionManager)
}
}
}
}

View file

@ -0,0 +1,36 @@
package com.tysmi.syncwave
import android.app.Application
import coil3.ImageLoader
import coil3.PlatformContext
import coil3.SingletonImageLoader
import coil3.disk.DiskCache
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
import coil3.request.crossfade
import okio.Path.Companion.toOkioPath
class SyncWaveApplication : Application(), SingletonImageLoader.Factory {
lateinit var container: AppContainer
private set
override fun onCreate() {
super.onCreate()
container = AppContainer(this)
}
/** Cover art loads through the authenticated client and is cached on disk between launches. */
override fun newImageLoader(context: PlatformContext): ImageLoader =
ImageLoader.Builder(context)
.components {
add(OkHttpNetworkFetcherFactory(callFactory = { container.authenticatedHttpClient }))
}
.diskCache {
DiskCache.Builder()
.directory(context.cacheDir.resolve("cover_art").toOkioPath())
.maxSizeBytes(250L * 1024 * 1024)
.build()
}
.crossfade(true)
.build()
}

View file

@ -0,0 +1,53 @@
package com.tysmi.syncwave.data.auth
import android.content.Context
import android.util.Base64
import android.util.Log
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
private val Context.credentialDataStore by preferencesDataStore(name = "credentials")
/**
* Saves the login encrypted on disk. Only an encrypted blob is written; the key to read it is
* held by the Android Keystore (see [KeystoreCipher]). The file is excluded from backups in
* `res/xml/backup_rules.xml` and `data_extraction_rules.xml`, since it can't be decrypted on
* another device anyway.
*/
class CredentialStore(
private val context: Context,
private val cipher: KeystoreCipher = KeystoreCipher("syncwave_credentials"),
) {
private val key = stringPreferencesKey("encrypted_credentials")
suspend fun load(): ServerCredentials? = withContext(Dispatchers.IO) {
val stored = context.credentialDataStore.data.first()[key] ?: return@withContext null
try {
val bytes = cipher.decrypt(Base64.decode(stored, Base64.NO_WRAP))
Json.decodeFromString<ServerCredentials>(bytes.decodeToString())
} catch (e: Exception) {
// Key lost (e.g. device restore) or data corrupted: forget it and ask to log in again.
Log.w(TAG, "Stored credentials unreadable; clearing", e)
clear()
null
}
}
suspend fun save(credentials: ServerCredentials) = withContext(Dispatchers.IO) {
val encrypted = cipher.encrypt(Json.encodeToString(credentials).encodeToByteArray())
context.credentialDataStore.edit { it[key] = Base64.encodeToString(encrypted, Base64.NO_WRAP) }
}
suspend fun clear() {
context.credentialDataStore.edit { it.remove(key) }
}
private companion object {
const val TAG = "CredentialStore"
}
}

View file

@ -0,0 +1,54 @@
package com.tysmi.syncwave.data.auth
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import java.security.KeyStore
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
/**
* AES-256-GCM encryption with a key that lives in the Android Keystore. The key never leaves
* the secure hardware, so the encrypted bytes on disk are useless if copied off the device.
*/
class KeystoreCipher(private val keyAlias: String) {
fun encrypt(plaintext: ByteArray): ByteArray {
val cipher = Cipher.getInstance(TRANSFORMATION)
cipher.init(Cipher.ENCRYPT_MODE, getOrCreateKey())
val iv = cipher.iv
return byteArrayOf(iv.size.toByte()) + iv + cipher.doFinal(plaintext)
}
fun decrypt(payload: ByteArray): ByteArray {
val ivSize = payload[0].toInt()
val iv = payload.copyOfRange(1, 1 + ivSize)
val cipher = Cipher.getInstance(TRANSFORMATION)
cipher.init(Cipher.DECRYPT_MODE, getOrCreateKey(), GCMParameterSpec(TAG_BITS, iv))
return cipher.doFinal(payload, 1 + ivSize, payload.size - 1 - ivSize)
}
private fun getOrCreateKey(): SecretKey {
val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) }
(keyStore.getKey(keyAlias, null) as? SecretKey)?.let { return it }
val spec = KeyGenParameterSpec.Builder(
keyAlias,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,
)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setKeySize(256)
.build()
return KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE)
.apply { init(spec) }
.generateKey()
}
private companion object {
const val ANDROID_KEYSTORE = "AndroidKeyStore"
const val TRANSFORMATION = "AES/GCM/NoPadding"
const val TAG_BITS = 128
}
}

View file

@ -0,0 +1,40 @@
package com.tysmi.syncwave.data.auth
import kotlinx.serialization.Serializable
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
/** Everything needed to talk to one Navidrome / OpenSubsonic server. */
@Serializable
data class ServerCredentials(
/** Normalized base URL, e.g. `https://music.example.com` (no trailing slash, no `/rest`). */
val serverUrl: String,
val username: String,
val password: String,
) {
val baseUrl: HttpUrl get() = serverUrl.toHttpUrlOrNull()
?: error("Stored server URL is invalid: $serverUrl")
// Keep the password out of logs and crash reports.
override fun toString() = "ServerCredentials(serverUrl=$serverUrl, username=$username)"
}
object ServerUrls {
/**
* Turns what the user typed into a clean base URL, or returns null if it can't be a URL.
* Accepts `music.example.com`, `192.168.1.5:4533`, `https://host/navidrome/`, or a pasted
* `.../rest` URL. Defaults to https when no scheme is given.
*/
fun normalize(input: String): String? {
var text = input.trim()
if (text.isEmpty()) return null
if (!text.contains("://")) text = "https://$text"
val url = text.toHttpUrlOrNull() ?: return null
val segments = url.encodedPathSegments.filter { it.isNotEmpty() }.toMutableList()
if (segments.lastOrNull() == "rest") segments.removeAt(segments.lastIndex)
val path = segments.joinToString("/")
val base = url.newBuilder().query(null).fragment(null).encodedPath("/").build().toString()
.trimEnd('/')
return if (path.isEmpty()) base else "$base/$path"
}
}

View file

@ -0,0 +1,70 @@
package com.tysmi.syncwave.data.auth
import com.tysmi.syncwave.data.subsonic.SubsonicApi
import com.tysmi.syncwave.data.subsonic.SubsonicApiFactory
import com.tysmi.syncwave.data.subsonic.orThrow
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
sealed interface SessionState {
/** Still reading saved credentials from disk at startup. */
data object Loading : SessionState
data object LoggedOut : SessionState
data class LoggedIn(val credentials: ServerCredentials) : SessionState
}
class InvalidServerUrlException : IllegalArgumentException("Invalid server URL")
/**
* Single source of truth for "who are we logged in as". The UI, the image loader, and (later)
* the playback service all observe [state] instead of reading storage themselves.
*/
class SessionManager(
private val store: CredentialStore,
private val apiFactory: SubsonicApiFactory,
scope: CoroutineScope,
) {
private val _state = MutableStateFlow<SessionState>(SessionState.Loading)
val state: StateFlow<SessionState> = _state.asStateFlow()
private var api: Pair<ServerCredentials, SubsonicApi>? = null
val currentCredentials: ServerCredentials?
get() = (_state.value as? SessionState.LoggedIn)?.credentials
init {
scope.launch {
_state.value = store.load()?.let { SessionState.LoggedIn(it) } ?: SessionState.LoggedOut
}
}
/**
* Checks the details against the server with a `ping`, and only saves them if it succeeds.
* Throws on failure (bad URL, network error, wrong password, ...).
*/
suspend fun login(serverUrl: String, username: String, password: String) {
val normalizedUrl = ServerUrls.normalize(serverUrl) ?: throw InvalidServerUrlException()
val credentials = ServerCredentials(normalizedUrl, username.trim(), password)
val candidateApi = apiFactory.create(credentials)
candidateApi.ping().orThrow()
store.save(credentials)
api = credentials to candidateApi
_state.value = SessionState.LoggedIn(credentials)
}
suspend fun logout() {
store.clear()
api = null
_state.value = SessionState.LoggedOut
}
/** API client for the logged-in account. Throws if logged out. */
fun requireApi(): SubsonicApi {
val credentials = currentCredentials ?: throw IllegalStateException("Not logged in")
api?.let { (forCredentials, cached) -> if (forCredentials == credentials) return cached }
return apiFactory.create(credentials).also { api = credentials to it }
}
}

View file

@ -0,0 +1,32 @@
package com.tysmi.syncwave.data.library
import com.tysmi.syncwave.data.auth.SessionManager
import com.tysmi.syncwave.data.subsonic.AlbumDto
import com.tysmi.syncwave.data.subsonic.SubsonicUrls
import com.tysmi.syncwave.data.subsonic.orThrow
import com.tysmi.syncwave.model.Album
/** Music library access for the rest of the app. Hides which API the data comes from. */
class LibraryRepository(
private val session: SessionManager,
) {
/** One page of albums, A–Z by name. An empty or short page means the end was reached. */
suspend fun getAlbums(offset: Int, size: Int): List<Album> =
session.requireApi()
.getAlbumList2(type = "alphabeticalByName", size = size, offset = offset)
.orThrow()
.albumList2?.album.orEmpty()
.map { it.toAlbum() }
fun coverArtUrl(coverArtId: String, sizePx: Int): String? =
session.currentCredentials?.let { SubsonicUrls.coverArt(it, coverArtId, sizePx) }
}
private fun AlbumDto.toAlbum() = Album(
id = id,
name = name,
artist = artist.orEmpty(),
year = year,
songCount = songCount,
coverArtId = coverArt,
)

View file

@ -0,0 +1,28 @@
package com.tysmi.syncwave.data.subsonic
import retrofit2.http.GET
import retrofit2.http.Query
/**
* OpenSubsonic endpoints. Paths are relative (no leading slash) so servers hosted under a
* sub-path like `https://host/navidrome` work. Auth and format params are added by
* [SubsonicAuthInterceptor]. Docs: https://opensubsonic.netlify.app/docs/endpoints/
*/
interface SubsonicApi {
@GET("rest/ping")
suspend fun ping(): SubsonicEnvelope
/** [type] is one of e.g. `alphabeticalByName`, `newest`, `recent`, `frequent`, `random`. */
@GET("rest/getAlbumList2")
suspend fun getAlbumList2(
@Query("type") type: String,
@Query("size") size: Int,
@Query("offset") offset: Int,
): SubsonicEnvelope
companion object {
/** Largest page the Subsonic API allows for album lists. */
const val MAX_ALBUM_PAGE_SIZE = 500
}
}

View file

@ -0,0 +1,41 @@
package com.tysmi.syncwave.data.subsonic
import com.tysmi.syncwave.data.auth.ServerCredentials
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import retrofit2.Retrofit
import retrofit2.converter.kotlinx.serialization.asConverterFactory
/** Builds a [SubsonicApi] bound to one server + account. Shares [httpClient]'s connection pool. */
class SubsonicApiFactory(
private val httpClient: OkHttpClient,
private val json: Json,
) {
fun create(credentials: ServerCredentials): SubsonicApi {
val client = httpClient.newBuilder()
.addInterceptor(SubsonicAuthInterceptor { credentials })
.build()
return Retrofit.Builder()
.baseUrl(credentials.serverUrl + "/")
.client(client)
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
.build()
.create(SubsonicApi::class.java)
}
}
/** Builds URLs for media endpoints that are loaded outside Retrofit (images now, streams later). */
object SubsonicUrls {
/**
* Cover art URL *without* credentials; the auth interceptor on the image loader's HTTP
* client adds them. Keeping them out of the URL means the image cache key stays stable.
*/
fun coverArt(credentials: ServerCredentials, coverArtId: String, sizePx: Int): String =
credentials.baseUrl.newBuilder()
.addPathSegments("rest/getCoverArt")
.addQueryParameter("id", coverArtId)
.addQueryParameter("size", sizePx.toString())
.build()
.toString()
}

View file

@ -0,0 +1,58 @@
package com.tysmi.syncwave.data.subsonic
import com.tysmi.syncwave.data.auth.ServerCredentials
import okhttp3.HttpUrl
import okhttp3.Interceptor
import okhttp3.Response
import java.security.MessageDigest
import java.security.SecureRandom
/**
* Subsonic token auth: the password is never sent. Each request carries a fresh random salt
* `s` and `t = md5(password + salt)`.
*/
object SubsonicAuth {
const val API_VERSION = "1.16.1"
const val CLIENT_NAME = "SyncWave"
private val random = SecureRandom()
fun newSalt(): String = ByteArray(8).also(random::nextBytes).toHex()
fun token(password: String, salt: String): String =
MessageDigest.getInstance("MD5").digest((password + salt).toByteArray()).toHex()
private fun ByteArray.toHex() = joinToString("") { "%02x".format(it) }
}
/**
* Adds auth + format query params to every request aimed at the user's server's `/rest/` API.
* Requests to any other host pass through untouched, so credentials can't leak elsewhere.
* [credentials] is read per request, so one client keeps working across login/logout.
*/
class SubsonicAuthInterceptor(
private val credentials: () -> ServerCredentials?,
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
val creds = credentials()
if (creds == null || !request.url.isSubsonicApiOf(creds.baseUrl)) {
return chain.proceed(request)
}
val salt = SubsonicAuth.newSalt()
val url = request.url.newBuilder()
.setQueryParameter("u", creds.username)
.setQueryParameter("t", SubsonicAuth.token(creds.password, salt))
.setQueryParameter("s", salt)
.setQueryParameter("v", SubsonicAuth.API_VERSION)
.setQueryParameter("c", SubsonicAuth.CLIENT_NAME)
.setQueryParameter("f", "json")
.build()
return chain.proceed(request.newBuilder().url(url).build())
}
private fun HttpUrl.isSubsonicApiOf(base: HttpUrl): Boolean =
scheme == base.scheme && host == base.host && port == base.port &&
encodedPath.startsWith(base.encodedPath.trimEnd('/') + "/rest/")
}

View file

@ -0,0 +1,66 @@
package com.tysmi.syncwave.data.subsonic
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
// Wire format of OpenSubsonic JSON responses (`f=json`). Only the fields we use are declared;
// unknown fields are ignored, so new server versions won't break parsing.
@Serializable
data class SubsonicEnvelope(
@SerialName("subsonic-response") val response: SubsonicResponse,
)
@Serializable
data class SubsonicResponse(
val status: String,
val version: String? = null,
/** Server software, e.g. "navidrome" (OpenSubsonic extension). */
val type: String? = null,
val serverVersion: String? = null,
val openSubsonic: Boolean = false,
val error: SubsonicErrorDto? = null,
val albumList2: AlbumList2Dto? = null,
)
@Serializable
data class SubsonicErrorDto(
val code: Int,
val message: String? = null,
)
@Serializable
data class AlbumList2Dto(
val album: List<AlbumDto> = emptyList(),
)
@Serializable
data class AlbumDto(
val id: String,
val name: String = "",
val artist: String? = null,
val artistId: String? = null,
val coverArt: String? = null,
val songCount: Int = 0,
val year: Int? = null,
)
/** The server answered, but with `status: "failed"`. See the Subsonic API error codes. */
class SubsonicException(val code: Int, message: String?) : Exception(message ?: "Subsonic error $code") {
companion object {
const val WRONG_CREDENTIALS = 40
const val TOKEN_AUTH_NOT_SUPPORTED = 41
const val AUTH_MECHANISM_NOT_SUPPORTED = 42
const val NOT_AUTHORIZED = 50
const val NOT_FOUND = 70
}
}
/** Returns the response if it succeeded, otherwise throws [SubsonicException]. */
fun SubsonicEnvelope.orThrow(): SubsonicResponse {
if (response.status != "ok") {
val error = response.error
throw SubsonicException(error?.code ?: 0, error?.message)
}
return response
}

View file

@ -0,0 +1,15 @@
package com.tysmi.syncwave.model
/**
* An album as the rest of the app sees it. Deliberately independent of the OpenSubsonic wire
* format so UI, playback, Android Auto and the brain service can all share it.
*/
data class Album(
val id: String,
val name: String,
val artist: String,
val year: Int?,
val songCount: Int,
/** Server-side cover art id; turn it into a URL with `SubsonicUrls.coverArt`. */
val coverArtId: String?,
)

View file

@ -0,0 +1,37 @@
package com.tysmi.syncwave.ui.common
import com.tysmi.syncwave.data.auth.InvalidServerUrlException
import com.tysmi.syncwave.data.subsonic.SubsonicException
import kotlinx.serialization.SerializationException
import retrofit2.HttpException
import java.io.IOException
import java.net.ConnectException
import java.net.SocketTimeoutException
import java.net.UnknownHostException
import javax.net.ssl.SSLException
/** Turns a failure into a sentence a person can act on. */
fun Throwable.toUserMessage(): String = when (this) {
is InvalidServerUrlException -> "That server address doesn't look right."
is SubsonicException -> when (code) {
SubsonicException.WRONG_CREDENTIALS -> "Wrong username or password."
SubsonicException.TOKEN_AUTH_NOT_SUPPORTED,
SubsonicException.AUTH_MECHANISM_NOT_SUPPORTED ->
"This server doesn't support SyncWave's login method."
SubsonicException.NOT_AUTHORIZED -> "This account isn't allowed to do that."
else -> "Server error: ${message ?: "code $code"}"
}
is UnknownHostException -> "Couldn't find that server. Check the address and your connection."
is ConnectException -> "Couldn't connect to the server. Is it running and reachable?"
is SocketTimeoutException -> "The server took too long to respond."
is SSLException -> "Secure connection failed. Check the address, or try http:// on your home network."
is HttpException -> if (code() == 404) {
"No music server found at that address."
} else {
"The server returned an error (HTTP ${code()})."
}
is SerializationException ->
"That address responded, but it doesn't look like a Navidrome server."
is IOException -> "Network error: ${message ?: "connection problem"}"
else -> "Something went wrong: ${message ?: javaClass.simpleName}"
}

View file

@ -0,0 +1,10 @@
package com.tysmi.syncwave.ui.common
import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY
import androidx.lifecycle.viewmodel.CreationExtras
import com.tysmi.syncwave.AppContainer
import com.tysmi.syncwave.SyncWaveApplication
/** Lets a ViewModel factory reach the app's [AppContainer]. */
fun CreationExtras.appContainer(): AppContainer =
(checkNotNull(this[APPLICATION_KEY]) as SyncWaveApplication).container

View file

@ -0,0 +1,155 @@
package com.tysmi.syncwave.ui.library
import androidx.compose.foundation.background
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.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import coil3.compose.AsyncImage
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AlbumGridScreen(viewModel: AlbumGridViewModel = viewModel(factory = AlbumGridViewModel.Factory)) {
val state by viewModel.state.collectAsStateWithLifecycle()
Scaffold(
topBar = {
TopAppBar(
title = { Text("Albums") },
actions = { TextButton(onClick = viewModel::signOut) { Text("Sign out") } },
)
},
) { innerPadding ->
PullToRefreshBox(
isRefreshing = state.isRefreshing,
onRefresh = viewModel::refresh,
modifier = Modifier.fillMaxSize().padding(innerPadding),
) {
when {
state.items.isEmpty() && state.error != null ->
CenteredMessage(state.error!!, actionLabel = "Retry", onAction = viewModel::retry)
state.items.isEmpty() && state.isLoading ->
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator() }
state.items.isEmpty() && state.endReached ->
CenteredMessage("No albums found on this server.")
else -> AlbumGrid(state, onLoadMore = viewModel::loadMore, onRetry = viewModel::retry)
}
}
}
}
@Composable
private fun AlbumGrid(state: AlbumGridUiState, onLoadMore: () -> Unit, onRetry: () -> Unit) {
val gridState = rememberLazyGridState()
// Ask for the next page when the user scrolls within ~2 rows of the end.
val nearEnd by remember {
derivedStateOf {
val lastVisible = gridState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
lastVisible >= gridState.layoutInfo.totalItemsCount - 10
}
}
LaunchedEffect(nearEnd, state.items.size) {
if (nearEnd) onLoadMore()
}
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 150.dp),
state = gridState,
contentPadding = PaddingValues(16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalArrangement = Arrangement.spacedBy(20.dp),
modifier = Modifier.fillMaxSize(),
) {
items(state.items, key = { it.album.id }) { item -> AlbumCard(item) }
if (state.isLoading || state.error != null) {
item(span = { GridItemSpan(maxLineSpan) }) {
Box(Modifier.fillMaxWidth().padding(16.dp), contentAlignment = Alignment.Center) {
if (state.error != null) {
TextButton(onClick = onRetry) { Text("Couldn't load more. Tap to retry.") }
} else {
CircularProgressIndicator()
}
}
}
}
}
}
@Composable
private fun AlbumCard(item: AlbumGridItem) {
Column {
AsyncImage(
model = item.coverArtUrl,
contentDescription = "Cover of ${item.album.name}",
contentScale = ContentScale.Crop,
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f)
.clip(RoundedCornerShape(8.dp))
.background(MaterialTheme.colorScheme.surfaceVariant),
)
Text(
item.album.name,
style = MaterialTheme.typography.titleSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 8.dp),
)
Text(
item.album.artist,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
@Composable
private fun CenteredMessage(message: String, actionLabel: String? = null, onAction: () -> Unit = {}) {
Column(
modifier = Modifier.fillMaxSize().padding(32.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(message, textAlign = TextAlign.Center, style = MaterialTheme.typography.bodyLarge)
if (actionLabel != null) {
Button(onClick = onAction, modifier = Modifier.padding(top = 16.dp)) { Text(actionLabel) }
}
}
}

View file

@ -0,0 +1,103 @@
package com.tysmi.syncwave.ui.library
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.tysmi.syncwave.data.auth.SessionManager
import com.tysmi.syncwave.data.library.LibraryRepository
import com.tysmi.syncwave.model.Album
import com.tysmi.syncwave.ui.common.appContainer
import com.tysmi.syncwave.ui.common.toUserMessage
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
data class AlbumGridItem(val album: Album, val coverArtUrl: String?)
data class AlbumGridUiState(
val items: List<AlbumGridItem> = emptyList(),
val isLoading: Boolean = false,
val isRefreshing: Boolean = false,
val endReached: Boolean = false,
val error: String? = null,
)
class AlbumGridViewModel(
private val library: LibraryRepository,
private val session: SessionManager,
) : ViewModel() {
private val _state = MutableStateFlow(AlbumGridUiState())
val state: StateFlow<AlbumGridUiState> = _state.asStateFlow()
private var loadJob: Job? = null
init {
loadMore()
}
/** Fetches the next page. Safe to call repeatedly (e.g. while scrolling). */
fun loadMore() {
val current = _state.value
if (loadJob?.isActive == true || current.endReached || current.error != null) return
load(offset = current.items.size, replace = false)
}
fun retry() {
_state.update { it.copy(error = null) }
loadMore()
}
fun refresh() {
loadJob?.cancel()
_state.update { it.copy(isRefreshing = true, error = null) }
load(offset = 0, replace = true)
}
fun signOut() {
viewModelScope.launch { session.logout() }
}
private fun load(offset: Int, replace: Boolean) {
_state.update { it.copy(isLoading = true) }
loadJob = viewModelScope.launch {
try {
val page = library.getAlbums(offset = offset, size = PAGE_SIZE)
val newItems = page.map { album ->
AlbumGridItem(album, album.coverArtId?.let { library.coverArtUrl(it, COVER_SIZE_PX) })
}
_state.update {
it.copy(
items = if (replace) newItems else it.items + newItems,
isLoading = false,
isRefreshing = false,
endReached = page.size < PAGE_SIZE,
)
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
_state.update {
it.copy(isLoading = false, isRefreshing = false, error = e.toUserMessage())
}
}
}
}
companion object {
private const val PAGE_SIZE = 100
private const val COVER_SIZE_PX = 400
val Factory = viewModelFactory {
initializer {
val container = appContainer()
AlbumGridViewModel(container.libraryRepository, container.sessionManager)
}
}
}
}

View file

@ -0,0 +1,167 @@
package com.tysmi.syncwave.ui.login
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
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.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.autofill.ContentType
import androidx.compose.ui.semantics.contentType
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.tysmi.syncwave.ui.theme.SyncWaveTheme
@Composable
fun LoginScreen(viewModel: LoginViewModel = viewModel(factory = LoginViewModel.Factory)) {
val state by viewModel.state.collectAsStateWithLifecycle()
LoginContent(
state = state,
onServerUrlChange = viewModel::onServerUrlChange,
onUsernameChange = viewModel::onUsernameChange,
onPasswordChange = viewModel::onPasswordChange,
onSignIn = viewModel::signIn,
)
}
@Composable
private fun LoginContent(
state: LoginUiState,
onServerUrlChange: (String) -> Unit,
onUsernameChange: (String) -> Unit,
onPasswordChange: (String) -> Unit,
onSignIn: () -> Unit,
) {
var passwordVisible by rememberSaveable { mutableStateOf(false) }
Surface(modifier = Modifier.fillMaxSize()) {
Column(
modifier = Modifier
.safeDrawingPadding()
.imePadding()
.verticalScroll(rememberScrollState())
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Spacer(Modifier.height(32.dp))
Text("SyncWave", style = MaterialTheme.typography.displaySmall)
Text(
"Sign in to your Navidrome server",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(8.dp))
val fieldModifier = Modifier.fillMaxWidth().widthIn(max = 480.dp)
OutlinedTextField(
value = state.serverUrl,
onValueChange = onServerUrlChange,
label = { Text("Server address") },
placeholder = { Text("music.example.com") },
supportingText = {
if (state.isInsecure) {
Text(
"Unencrypted connection. Fine on your home network; use https:// over the internet.",
color = MaterialTheme.colorScheme.tertiary,
)
} else {
Text("e.g. https://music.example.com or http://192.168.1.10:4533")
}
},
singleLine = true,
enabled = !state.isSigningIn,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Uri,
imeAction = ImeAction.Next,
autoCorrectEnabled = false,
),
modifier = fieldModifier,
)
OutlinedTextField(
value = state.username,
onValueChange = onUsernameChange,
label = { Text("Username") },
singleLine = true,
enabled = !state.isSigningIn,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next, autoCorrectEnabled = false),
modifier = fieldModifier.semantics { contentType = ContentType.Username },
)
OutlinedTextField(
value = state.password,
onValueChange = onPasswordChange,
label = { Text("Password") },
singleLine = true,
enabled = !state.isSigningIn,
visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
trailingIcon = {
TextButton(onClick = { passwordVisible = !passwordVisible }) {
Text(if (passwordVisible) "Hide" else "Show")
}
},
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Go,
),
keyboardActions = KeyboardActions(onGo = { onSignIn() }),
modifier = fieldModifier.semantics { contentType = ContentType.Password },
)
state.error?.let {
Text(it, color = MaterialTheme.colorScheme.error, modifier = fieldModifier)
}
Button(onClick = onSignIn, enabled = state.canSubmit, modifier = fieldModifier.height(48.dp)) {
if (state.isSigningIn) {
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
} else {
Text("Sign in")
}
}
}
}
}
@Preview(showBackground = true)
@Composable
private fun LoginPreview() {
SyncWaveTheme {
LoginContent(
state = LoginUiState(serverUrl = "http://192.168.1.10:4533", username = "ty", error = "Wrong username or password."),
onServerUrlChange = {}, onUsernameChange = {}, onPasswordChange = {}, onSignIn = {},
)
}
}

View file

@ -0,0 +1,59 @@
package com.tysmi.syncwave.ui.login
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.tysmi.syncwave.data.auth.SessionManager
import com.tysmi.syncwave.ui.common.appContainer
import com.tysmi.syncwave.ui.common.toUserMessage
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
data class LoginUiState(
val serverUrl: String = "",
val username: String = "",
val password: String = "",
val isSigningIn: Boolean = false,
val error: String? = null,
) {
val canSubmit get() = serverUrl.isNotBlank() && username.isNotBlank() && password.isNotEmpty() && !isSigningIn
val isInsecure get() = serverUrl.trim().startsWith("http://", ignoreCase = true)
}
class LoginViewModel(private val session: SessionManager) : ViewModel() {
private val _state = MutableStateFlow(LoginUiState())
val state: StateFlow<LoginUiState> = _state.asStateFlow()
fun onServerUrlChange(value: String) = _state.update { it.copy(serverUrl = value, error = null) }
fun onUsernameChange(value: String) = _state.update { it.copy(username = value, error = null) }
fun onPasswordChange(value: String) = _state.update { it.copy(password = value, error = null) }
fun signIn() {
val current = _state.value
if (!current.canSubmit) return
_state.update { it.copy(isSigningIn = true, error = null) }
viewModelScope.launch {
try {
session.login(current.serverUrl, current.username, current.password)
// The app switches to the library on its own once the session is logged in.
_state.update { it.copy(isSigningIn = false, password = "") }
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
_state.update { it.copy(isSigningIn = false, error = e.toUserMessage()) }
}
}
}
companion object {
val Factory = viewModelFactory {
initializer { LoginViewModel(appContainer().sessionManager) }
}
}
}

View file

@ -0,0 +1,48 @@
package com.tysmi.syncwave.ui.navigation
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import com.tysmi.syncwave.data.auth.SessionManager
import com.tysmi.syncwave.data.auth.SessionState
import com.tysmi.syncwave.ui.library.AlbumGridScreen
import com.tysmi.syncwave.ui.login.LoginScreen
import kotlinx.serialization.Serializable
/** Screens inside the logged-in app. Add album detail, now playing, etc. here. */
@Serializable
data object AlbumsRoute
/**
* Top of the UI: shows login when logged out and the main app when logged in. Logging out
* from anywhere drops back to login automatically.
*/
@Composable
fun SyncWaveRoot(sessionManager: SessionManager) {
val session by sessionManager.state.collectAsStateWithLifecycle()
when (val s = session) {
SessionState.Loading ->
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator() }
SessionState.LoggedOut -> LoginScreen()
// Keyed on the account so switching servers starts with fresh screens and data.
is SessionState.LoggedIn -> key(s.credentials) { MainNavHost() }
}
}
@Composable
private fun MainNavHost() {
val navController = rememberNavController()
NavHost(navController = navController, startDestination = AlbumsRoute) {
composable<AlbumsRoute> { AlbumGridScreen() }
}
}

View file

@ -0,0 +1,11 @@
package com.tysmi.syncwave.ui.theme
import androidx.compose.ui.graphics.Color
val Purple80 = Color(0xFFD0BCFF)
val PurpleGrey80 = Color(0xFFCCC2DC)
val Pink80 = Color(0xFFEFB8C8)
val Purple40 = Color(0xFF6650a4)
val PurpleGrey40 = Color(0xFF625b71)
val Pink40 = Color(0xFF7D5260)

View file

@ -0,0 +1,58 @@
package com.tysmi.syncwave.ui.theme
import android.app.Activity
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
private val DarkColorScheme = darkColorScheme(
primary = Purple80,
secondary = PurpleGrey80,
tertiary = Pink80
)
private val LightColorScheme = lightColorScheme(
primary = Purple40,
secondary = PurpleGrey40,
tertiary = Pink40
/* Other default colors to override
background = Color(0xFFFFFBFE),
surface = Color(0xFFFFFBFE),
onPrimary = Color.White,
onSecondary = Color.White,
onTertiary = Color.White,
onBackground = Color(0xFF1C1B1F),
onSurface = Color(0xFF1C1B1F),
*/
)
@Composable
fun SyncWaveTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
// Dynamic color is available on Android 12+
dynamicColor: Boolean = true,
content: @Composable () -> Unit
) {
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> DarkColorScheme
else -> LightColorScheme
}
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
content = content
)
}

View file

@ -0,0 +1,34 @@
package com.tysmi.syncwave.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
// Set of Material typography styles to start with
val Typography = Typography(
bodyLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp
)
/* Other default text styles to override
titleLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 22.sp,
lineHeight = 28.sp,
letterSpacing = 0.sp
),
labelSmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 11.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp
)
*/
)

View file

@ -0,0 +1,12 @@
# Add project specific R8 rules here.
# AGP will combine all keep rule files in src/main/keepRules to pass to R8
#
# For more details, see
# https://d.android.com/r/tools/r8/keep-rules
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}

View file

@ -0,0 +1,170 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
</vector>

View file

@ -0,0 +1,30 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
</resources>

View file

@ -0,0 +1,3 @@
<resources>
<string name="app_name">SyncWave</string>
</resources>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.SyncWave" parent="android:Theme.Material.Light.NoActionBar" />
</resources>

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Backup rules for Android 9-11. The encrypted login is excluded: its key lives in this
device's Keystore, so a restored copy could never be decrypted.
-->
<full-backup-content>
<exclude domain="file" path="datastore/credentials.preferences_pb" />
</full-backup-content>

View file

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Backup rules for Android 12+. The encrypted login is excluded: its key lives in this
device's Keystore, so a restored or transferred copy could never be decrypted.
-->
<data-extraction-rules>
<cloud-backup>
<exclude domain="file" path="datastore/credentials.preferences_pb" />
</cloud-backup>
<device-transfer>
<exclude domain="file" path="datastore/credentials.preferences_pb" />
</device-transfer>
</data-extraction-rules>

View file

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
A self-hosted Navidrome server is often reached over plain http on the home network
(e.g. http://192.168.1.10:4533), so cleartext is allowed. The login screen warns when an
http:// address is used. User-installed CAs are trusted so a server with a self-signed
certificate works once its CA is installed on the phone.
-->
<network-security-config>
<base-config cleartextTrafficPermitted="true">
<trust-anchors>
<certificates src="system" />
<certificates src="user" />
</trust-anchors>
</base-config>
</network-security-config>

View file

@ -0,0 +1,34 @@
package com.tysmi.syncwave.data.auth
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class ServerUrlsTest {
@Test
fun `adds https when no scheme is given`() {
assertEquals("https://music.example.com", ServerUrls.normalize("music.example.com"))
}
@Test
fun `keeps http and port for LAN addresses`() {
assertEquals("http://192.168.1.10:4533", ServerUrls.normalize(" http://192.168.1.10:4533/ "))
}
@Test
fun `keeps a sub-path but strips a pasted rest suffix`() {
assertEquals("https://host.example/navidrome", ServerUrls.normalize("https://host.example/navidrome/rest/"))
}
@Test
fun `drops query and fragment`() {
assertEquals("https://host.example", ServerUrls.normalize("https://host.example/?foo=bar#x"))
}
@Test
fun `rejects blank and garbage input`() {
assertNull(ServerUrls.normalize(" "))
assertNull(ServerUrls.normalize("ftp://host"))
}
}

View file

@ -0,0 +1,114 @@
package com.tysmi.syncwave.data.subsonic
import com.tysmi.syncwave.data.auth.ServerCredentials
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.Json
import mockwebserver3.MockResponse
import mockwebserver3.MockWebServer
import okhttp3.OkHttpClient
import okhttp3.Request
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertNull
import org.junit.Assert.fail
import org.junit.Before
import org.junit.Test
class SubsonicApiTest {
private val server = MockWebServer()
private val json = Json { ignoreUnknownKeys = true; coerceInputValues = true }
@Before fun setUp() = server.start()
@After fun tearDown() = server.close()
private fun credentials(path: String = "") =
ServerCredentials(server.url(path).toString().trimEnd('/'), "ty", "sesame")
private fun api(creds: ServerCredentials = credentials()) =
SubsonicApiFactory(OkHttpClient(), json).create(creds)
private fun respond(body: String) = server.enqueue(MockResponse.Builder().body(body).build())
@Test
fun `token matches the example in the Subsonic API docs`() {
assertEquals("26719a1196d2a940705a59634eb18eab", SubsonicAuth.token("sesame", "c19b2d"))
}
@Test
fun `salts are random`() {
assertNotEquals(SubsonicAuth.newSalt(), SubsonicAuth.newSalt())
}
@Test
fun `requests carry token auth and never the password`() = runTest {
respond("""{"subsonic-response":{"status":"ok","version":"1.16.1","type":"navidrome"}}""")
api().ping().orThrow()
val url = server.takeRequest().url
assertEquals("/rest/ping", url.encodedPath)
assertEquals("ty", url.queryParameter("u"))
assertEquals("json", url.queryParameter("f"))
assertEquals(SubsonicAuth.token("sesame", url.queryParameter("s")!!), url.queryParameter("t"))
assertNull(url.queryParameter("p"))
}
@Test
fun `servers under a sub-path are supported`() = runTest {
respond("""{"subsonic-response":{"status":"ok"}}""")
api(credentials("/navidrome")).ping()
assertEquals("/navidrome/rest/ping", server.takeRequest().url.encodedPath)
}
@Test
fun `failed status becomes SubsonicException with the error code`() = runTest {
respond("""{"subsonic-response":{"status":"failed","error":{"code":40,"message":"Wrong username or password"}}}""")
try {
api().ping().orThrow()
fail("Expected SubsonicException")
} catch (e: SubsonicException) {
assertEquals(SubsonicException.WRONG_CREDENTIALS, e.code)
}
}
@Test
fun `album list parses and tolerates missing and unknown fields`() = runTest {
respond(
"""{"subsonic-response":{"status":"ok","albumList2":{"album":[
{"id":"a1","name":"Kid A","artist":"Radiohead","coverArt":"al-a1","songCount":10,"year":2000,"isDir":true},
{"id":"a2","name":"Untitled","year":null}
]}}}""",
)
val albums = api().getAlbumList2("alphabeticalByName", size = 100, offset = 0).orThrow().albumList2!!.album
assertEquals(listOf("a1", "a2"), albums.map { it.id })
assertEquals("al-a1", albums[0].coverArt)
assertNull(albums[1].coverArt)
assertEquals("alphabeticalByName", server.takeRequest().url.queryParameter("type"))
}
@Test
fun `auth is not added to requests for other hosts`() {
respond("ok")
val elsewhere = server.url("/rest/ping")
// Credentials for a different host than the one being requested.
val creds = ServerCredentials("https://music.example.com", "ty", "sesame")
val client = OkHttpClient.Builder().addInterceptor(SubsonicAuthInterceptor { creds }).build()
client.newCall(Request.Builder().url(elsewhere).build()).execute().close()
assertNull(server.takeRequest().url.queryParameter("u"))
}
@Test
fun `cover art url has no credentials in it`() {
val url = SubsonicUrls.coverArt(ServerCredentials("https://music.example.com", "ty", "sesame"), "al-1", 400)
assertEquals("https://music.example.com/rest/getCoverArt?id=al-1&size=400", url)
}
}

14
build.gradle.kts Normal file
View file

@ -0,0 +1,14 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
dependencies {
// AGP 9 ships with an older built-in Kotlin; pin the Kotlin compiler to match the
// Compose and serialization compiler plugins declared below.
classpath(libs.kotlin.gradle.plugin)
}
}
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.compose) apply false
alias(libs.plugins.kotlin.serialization) apply false
}

19
gradle.properties Normal file
View file

@ -0,0 +1,19 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. For more details, visit
# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
# org.gradle.parallel=true
# When enabled, the Configuration Cache allows Gradle to skip the configuration
# phase entirely if nothing that affects the build configuration (such as build scripts)
# has changed. Additionally, Gradle applies performance optimizations to task execution.
org.gradle.configuration-cache=true
# Kotlin code style for this project: "official" or "obsolete":
kotlin.code.style=official

View file

@ -0,0 +1,12 @@
#This file is generated by updateDaemonJvm
toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/cf726b4a1c84b50457225f9bba6d7650/redirect
toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/fa1e318c287360478e3c83a9a3ef1007/redirect
toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/cf726b4a1c84b50457225f9bba6d7650/redirect
toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/fa1e318c287360478e3c83a9a3ef1007/redirect
toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/c2dd35c9d0aaf0ba6ad0791320f99dfc/redirect
toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/e5810bd7fd1f8a586644409d395a7e55/redirect
toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/cf726b4a1c84b50457225f9bba6d7650/redirect
toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/fa1e318c287360478e3c83a9a3ef1007/redirect
toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/7b3c4877c0749019e6805bb61e421497/redirect
toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/d76df094a9cbbabd3b08251f9e61444a/redirect
toolchainVersion=25

55
gradle/libs.versions.toml Normal file
View file

@ -0,0 +1,55 @@
[versions]
agp = "9.4.1"
kotlin = "2.4.10"
coreKtx = "1.18.0"
activityCompose = "1.12.4"
lifecycle = "2.10.0"
navigationCompose = "2.10.1"
datastore = "1.2.0"
composeBom = "2026.08.00"
retrofit = "3.0.0"
okhttp = "5.4.0"
kotlinxSerialization = "1.10.0"
coil = "3.6.2"
coroutines = "1.10.2"
junit = "4.13.2"
junitVersion = "1.1.5"
espressoCore = "3.5.1"
[libraries]
kotlin-gradle-plugin = { group = "org.jetbrains.kotlin", name = "kotlin-gradle-plugin", version.ref = "kotlin" }
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" }
androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" }
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" }
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
retrofit-kotlinx-serialization = { group = "com.squareup.retrofit2", name = "converter-kotlinx-serialization", version.ref = "retrofit" }
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
okhttp-mockwebserver = { group = "com.squareup.okhttp3", name = "mockwebserver3", version.ref = "okhttp" }
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerialization" }
coil-compose = { group = "io.coil-kt.coil3", name = "coil-compose", version.ref = "coil" }
coil-network-okhttp = { group = "io.coil-kt.coil3", name = "coil-network-okhttp", version.ref = "coil" }
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }

BIN
gradle/wrapper/gradle-wrapper.jar vendored Normal file

Binary file not shown.

View file

@ -0,0 +1,9 @@
#Sun Oct 11 18:18:58 EDT 2026
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionSha256Sum=bbaeb2fef8710818cf0e261201dab964c572f92b942812df0c3620d62a529a01
distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.0-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

251
gradlew vendored Normal file
View file

@ -0,0 +1,251 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

94
gradlew.bat vendored Normal file
View file

@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

26
settings.gradle.kts Normal file
View file

@ -0,0 +1,26 @@
pluginManagement {
repositories {
google {
content {
includeGroupByRegex("com\\.android.*")
includeGroupByRegex("com\\.google.*")
includeGroupByRegex("androidx.*")
}
}
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "SyncWave"
include(":app")