commit 1971d02e2b7ba33e3260a0ab82ed1297afee666b Author: Netherwarlord Date: Wed Sep 16 23:38:10 2026 -0400 Initial commit: Deepwave Phase 1 - auth scaffolding Spotify-like Navidrome client scaffold with Hilt DI, a Subsonic API client (token auth, base-URL rewriting), encrypted single-server credential storage (DataStore + Tink/Keystore), and a login/session flow gating Compose Navigation between Login and a placeholder Home screen. Co-Authored-By: Claude Sonnet 5 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aa724b7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +*.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 diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..c341901 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,77 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.ksp) + alias(libs.plugins.hilt.android) +} + +android { + namespace = "com.InfernalAquatics.deepwave" + compileSdk { + version = release(37) + } + + defaultConfig { + applicationId = "com.InfernalAquatics.deepwave" + minSdk = 26 + targetSdk = 37 + versionCode = 1 + versionName = "1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + optimization { + enable = false + } + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + buildFeatures { + compose = true + buildConfig = 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.viewmodel.compose) + implementation(libs.androidx.navigation.compose) + + // Dependency injection + implementation(libs.hilt.android) + ksp(libs.hilt.compiler) + implementation(libs.androidx.hilt.lifecycle.viewmodel.compose) + + // Networking (Subsonic/Navidrome API client) + implementation(libs.retrofit) + implementation(libs.retrofit.kotlinx.serialization.converter) + implementation(libs.okhttp) + implementation(libs.okhttp.logging.interceptor) + implementation(libs.kotlinx.serialization.json) + + // Encrypted credential storage (DataStore + Tink/Keystore) + implementation(libs.androidx.datastore.preferences) + implementation(libs.tink.android) + + testImplementation(libs.junit) + 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) +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/InfernalAquatics/deepwave/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/InfernalAquatics/deepwave/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..c4f7a5c --- /dev/null +++ b/app/src/androidTest/java/com/InfernalAquatics/deepwave/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package com.InfernalAquatics.deepwave + +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.InfernalAquatics.deepwave", appContext.packageName) + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..2de0796 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/DeepwaveApplication.kt b/app/src/main/java/com/InfernalAquatics/deepwave/DeepwaveApplication.kt new file mode 100644 index 0000000..c88815d --- /dev/null +++ b/app/src/main/java/com/InfernalAquatics/deepwave/DeepwaveApplication.kt @@ -0,0 +1,7 @@ +package com.InfernalAquatics.deepwave + +import android.app.Application +import dagger.hilt.android.HiltAndroidApp + +@HiltAndroidApp +class DeepwaveApplication : Application() diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/MainActivity.kt b/app/src/main/java/com/InfernalAquatics/deepwave/MainActivity.kt new file mode 100644 index 0000000..e4df435 --- /dev/null +++ b/app/src/main/java/com/InfernalAquatics/deepwave/MainActivity.kt @@ -0,0 +1,22 @@ +package com.InfernalAquatics.deepwave + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import com.InfernalAquatics.deepwave.ui.navigation.DeepwaveNavHost +import com.InfernalAquatics.deepwave.ui.theme.DeepwaveTheme +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { + DeepwaveTheme { + DeepwaveNavHost() + } + } + } +} diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/data/network/SubsonicApi.kt b/app/src/main/java/com/InfernalAquatics/deepwave/data/network/SubsonicApi.kt new file mode 100644 index 0000000..b647ec7 --- /dev/null +++ b/app/src/main/java/com/InfernalAquatics/deepwave/data/network/SubsonicApi.kt @@ -0,0 +1,15 @@ +package com.InfernalAquatics.deepwave.data.network + +import com.InfernalAquatics.deepwave.data.network.model.PingResponse +import retrofit2.http.GET + +/** + * Subsonic API surface implemented by Navidrome. The base URL is a placeholder + * (see NetworkModule) - [SubsonicRequestInterceptor] rewrites every request onto + * the user's actual server URL and appends auth params, so callers never touch + * server address or credentials directly. + */ +interface SubsonicApi { + @GET("rest/ping") + suspend fun ping(): PingResponse +} diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/data/network/SubsonicRequestInterceptor.kt b/app/src/main/java/com/InfernalAquatics/deepwave/data/network/SubsonicRequestInterceptor.kt new file mode 100644 index 0000000..a0ff57b --- /dev/null +++ b/app/src/main/java/com/InfernalAquatics/deepwave/data/network/SubsonicRequestInterceptor.kt @@ -0,0 +1,63 @@ +package com.InfernalAquatics.deepwave.data.network + +import com.InfernalAquatics.deepwave.data.security.CredentialsStore +import kotlinx.coroutines.runBlocking +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.Interceptor +import okhttp3.Response +import java.security.MessageDigest +import java.security.SecureRandom +import javax.inject.Inject + +private const val SUBSONIC_API_VERSION = "1.16.1" +private const val CLIENT_NAME = "Deepwave" + +/** + * Every Subsonic/Navidrome request goes out through this OkHttpClient. It rewrites + * the placeholder base URL onto the user's real, currently-saved server URL (kept as + * a path-preserving rewrite so a reverse-proxy path prefix on the server URL isn't + * dropped) and appends the Subsonic token-auth query params, so nothing above the + * network layer - Retrofit interfaces, Media3's stream/download URLs - needs to know + * the server address or credentials. + */ +class SubsonicRequestInterceptor @Inject constructor( + private val credentialsStore: CredentialsStore, +) : Interceptor { + + override fun intercept(chain: Interceptor.Chain): Response { + val credentials = runBlocking { credentialsStore.currentForRequest() } + ?: return chain.proceed(chain.request()) + + val serverHttpUrl = credentials.serverUrl.toHttpUrlOrNull() + ?: return chain.proceed(chain.request()) + + val original = chain.request() + val salt = randomSalt() + val token = md5Hex(credentials.password + salt) + + val rewrittenUrl = serverHttpUrl.newBuilder() + .addEncodedPathSegments(original.url.encodedPath.removePrefix("/")) + .encodedQuery(original.url.encodedQuery) + .addQueryParameter("u", credentials.username) + .addQueryParameter("t", token) + .addQueryParameter("s", salt) + .addQueryParameter("v", SUBSONIC_API_VERSION) + .addQueryParameter("c", CLIENT_NAME) + .addQueryParameter("f", "json") + .build() + + val newRequest = original.newBuilder().url(rewrittenUrl).build() + return chain.proceed(newRequest) + } + + private fun randomSalt(): String { + val bytes = ByteArray(12) + SecureRandom().nextBytes(bytes) + return bytes.joinToString("") { "%02x".format(it) } + } + + private fun md5Hex(input: String): String { + val digest = MessageDigest.getInstance("MD5").digest(input.toByteArray(Charsets.UTF_8)) + return digest.joinToString("") { "%02x".format(it) } + } +} diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/data/network/model/SubsonicModels.kt b/app/src/main/java/com/InfernalAquatics/deepwave/data/network/model/SubsonicModels.kt new file mode 100644 index 0000000..47e238d --- /dev/null +++ b/app/src/main/java/com/InfernalAquatics/deepwave/data/network/model/SubsonicModels.kt @@ -0,0 +1,24 @@ +package com.InfernalAquatics.deepwave.data.network.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class SubsonicError( + val code: Int, + val message: String, +) + +@Serializable +data class PingResponse( + @SerialName("subsonic-response") val subsonicResponse: PingBody, +) + +@Serializable +data class PingBody( + val status: String, + val version: String, + val error: SubsonicError? = null, +) { + val isOk: Boolean get() = status == "ok" +} diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/data/repository/ServerRepository.kt b/app/src/main/java/com/InfernalAquatics/deepwave/data/repository/ServerRepository.kt new file mode 100644 index 0000000..cb95df8 --- /dev/null +++ b/app/src/main/java/com/InfernalAquatics/deepwave/data/repository/ServerRepository.kt @@ -0,0 +1,57 @@ +package com.InfernalAquatics.deepwave.data.repository + +import com.InfernalAquatics.deepwave.data.network.SubsonicApi +import com.InfernalAquatics.deepwave.data.security.CredentialsStore +import com.InfernalAquatics.deepwave.data.security.ServerCredentials +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import retrofit2.HttpException +import java.io.IOException +import javax.inject.Inject +import javax.inject.Singleton + +sealed interface LoginResult { + data object Success : LoginResult + data class Failure(val message: String) : LoginResult +} + +/** + * Owns the single logged-in Navidrome server/account: logging in (validated via + * `ping`), persisting credentials, and reporting session state. + */ +@Singleton +class ServerRepository @Inject constructor( + private val credentialsStore: CredentialsStore, + private val subsonicApi: SubsonicApi, +) { + val isLoggedIn: Flow = credentialsStore.credentials.map { it != null } + + suspend fun login(serverUrl: String, username: String, password: String): LoginResult { + val normalizedUrl = serverUrl.trim().trimEnd('/') + "/" + val candidate = ServerCredentials(normalizedUrl, username.trim(), password) + + // Held only in memory until ping confirms them, so SubsonicRequestInterceptor + // can sign the validating request without a failed attempt ever touching the + // persisted store that session state (and thus navigation) observes. + credentialsStore.setPendingCandidate(candidate) + try { + val response = subsonicApi.ping().subsonicResponse + return if (response.isOk) { + credentialsStore.save(candidate) + LoginResult.Success + } else { + LoginResult.Failure(response.error?.message ?: "Login failed") + } + } catch (e: IOException) { + return LoginResult.Failure("Couldn't reach server: ${e.message}") + } catch (e: HttpException) { + return LoginResult.Failure("Server error: ${e.message()}") + } finally { + credentialsStore.clearPendingCandidate() + } + } + + suspend fun logout() { + credentialsStore.clear() + } +} diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/data/security/CredentialsStore.kt b/app/src/main/java/com/InfernalAquatics/deepwave/data/security/CredentialsStore.kt new file mode 100644 index 0000000..a18eb28 --- /dev/null +++ b/app/src/main/java/com/InfernalAquatics/deepwave/data/security/CredentialsStore.kt @@ -0,0 +1,104 @@ +package com.InfernalAquatics.deepwave.data.security + +import android.content.Context +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import com.google.crypto.tink.Aead +import com.google.crypto.tink.KeyTemplates +import com.google.crypto.tink.aead.AeadConfig +import com.google.crypto.tink.integration.android.AndroidKeysetManager +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import java.util.Base64 +import javax.inject.Inject +import javax.inject.Singleton + +private val Context.credentialsDataStore by preferencesDataStore(name = "deepwave_credentials") + +/** The user's single self-hosted Navidrome server + Subsonic API login. */ +@Serializable +data class ServerCredentials( + val serverUrl: String, + val username: String, + val password: String, +) + +/** + * Persists [ServerCredentials] encrypted at rest. The blob is encrypted with a Tink + * AEAD primitive whose key is wrapped by an Android Keystore master key (the + * currently recommended replacement for the now-deprecated EncryptedSharedPreferences), + * then stored as a single opaque string in Preferences DataStore. + */ +@Singleton +class CredentialsStore @Inject constructor( + @ApplicationContext private val context: Context, +) { + private val credentialsKey = stringPreferencesKey("encrypted_credentials") + + // Holds not-yet-confirmed credentials while a login attempt is in flight, so + // SubsonicRequestInterceptor can sign the validating `ping` call with them + // without touching the persisted store that [credentials]/session state reads - + // otherwise a failed login attempt would flash the UI to "logged in" and back. + private val pendingCandidate = MutableStateFlow(null) + + private val aead: Aead by lazy { + AeadConfig.register() + AndroidKeysetManager.Builder() + .withSharedPref(context, "deepwave_master_keyset", "deepwave_master_keyset_prefs") + .withKeyTemplate(KeyTemplates.get("AES256_GCM")) + .withMasterKeyUri("android-keystore://deepwave_master_key") + .build() + .keysetHandle + .getPrimitive(Aead::class.java) + } + + val credentials: Flow = + context.credentialsDataStore.data.map { prefs -> + prefs[credentialsKey]?.let(::decrypt) + } + + suspend fun get(): ServerCredentials? = credentials.first() + + /** What SubsonicRequestInterceptor should sign requests with right now. */ + suspend fun currentForRequest(): ServerCredentials? = pendingCandidate.value ?: get() + + fun setPendingCandidate(credentials: ServerCredentials) { + pendingCandidate.value = credentials + } + + fun clearPendingCandidate() { + pendingCandidate.value = null + } + + suspend fun save(credentials: ServerCredentials) { + context.credentialsDataStore.edit { prefs -> + prefs[credentialsKey] = encrypt(credentials) + } + } + + suspend fun clear() { + context.credentialsDataStore.edit { prefs -> + prefs.remove(credentialsKey) + } + } + + private fun encrypt(credentials: ServerCredentials): String { + val plaintext = Json.encodeToString(credentials).toByteArray(Charsets.UTF_8) + val ciphertext = aead.encrypt(plaintext, null) + return Base64.getEncoder().encodeToString(ciphertext) + } + + private fun decrypt(encoded: String): ServerCredentials? = runCatching { + val ciphertext = Base64.getDecoder().decode(encoded) + val plaintext = aead.decrypt(ciphertext, null) + Json.decodeFromString(plaintext.toString(Charsets.UTF_8)) + }.getOrNull() +} diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/di/NetworkModule.kt b/app/src/main/java/com/InfernalAquatics/deepwave/di/NetworkModule.kt new file mode 100644 index 0000000..d86ae77 --- /dev/null +++ b/app/src/main/java/com/InfernalAquatics/deepwave/di/NetworkModule.kt @@ -0,0 +1,59 @@ +package com.InfernalAquatics.deepwave.di + +import com.InfernalAquatics.deepwave.BuildConfig +import com.InfernalAquatics.deepwave.data.network.SubsonicApi +import com.InfernalAquatics.deepwave.data.network.SubsonicRequestInterceptor +import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import kotlinx.serialization.json.Json +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import retrofit2.Retrofit +import javax.inject.Singleton + +/** + * Retrofit's base URL is a non-functional placeholder: [SubsonicRequestInterceptor] + * rewrites every outgoing request onto the user's actual saved server URL. + */ +private const val PLACEHOLDER_BASE_URL = "http://localhost/" + +@Module +@InstallIn(SingletonComponent::class) +object NetworkModule { + + @Provides + @Singleton + fun provideJson(): Json = Json { + ignoreUnknownKeys = true + isLenient = true + } + + @Provides + @Singleton + fun provideOkHttpClient( + subsonicRequestInterceptor: SubsonicRequestInterceptor, + ): OkHttpClient = OkHttpClient.Builder() + .addInterceptor(subsonicRequestInterceptor) + .apply { + if (BuildConfig.DEBUG) { + addInterceptor(HttpLoggingInterceptor().setLevel(HttpLoggingInterceptor.Level.BASIC)) + } + } + .build() + + @Provides + @Singleton + fun provideRetrofit(okHttpClient: OkHttpClient, json: Json): Retrofit = Retrofit.Builder() + .baseUrl(PLACEHOLDER_BASE_URL) + .client(okHttpClient) + .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) + .build() + + @Provides + @Singleton + fun provideSubsonicApi(retrofit: Retrofit): SubsonicApi = retrofit.create(SubsonicApi::class.java) +} diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/ui/home/HomeScreen.kt b/app/src/main/java/com/InfernalAquatics/deepwave/ui/home/HomeScreen.kt new file mode 100644 index 0000000..425eaba --- /dev/null +++ b/app/src/main/java/com/InfernalAquatics/deepwave/ui/home/HomeScreen.kt @@ -0,0 +1,37 @@ +package com.InfernalAquatics.deepwave.ui.home + +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.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel + +/** Placeholder post-login landing screen; replaced by the library browse UI in Phase 2. */ +@Composable +fun HomeScreen( + onLoggedOut: () -> Unit, + viewModel: HomeViewModel = hiltViewModel(), +) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text(text = "You're connected", style = MaterialTheme.typography.headlineSmall) + Spacer(modifier = Modifier.height(24.dp)) + Button(onClick = { viewModel.logout(onLoggedOut) }) { + Text("Log out") + } + } +} diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/ui/home/HomeViewModel.kt b/app/src/main/java/com/InfernalAquatics/deepwave/ui/home/HomeViewModel.kt new file mode 100644 index 0000000..adbd3ce --- /dev/null +++ b/app/src/main/java/com/InfernalAquatics/deepwave/ui/home/HomeViewModel.kt @@ -0,0 +1,20 @@ +package com.InfernalAquatics.deepwave.ui.home + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.InfernalAquatics.deepwave.data.repository.ServerRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class HomeViewModel @Inject constructor( + private val serverRepository: ServerRepository, +) : ViewModel() { + fun logout(onLoggedOut: () -> Unit) { + viewModelScope.launch { + serverRepository.logout() + onLoggedOut() + } + } +} diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/ui/login/LoginScreen.kt b/app/src/main/java/com/InfernalAquatics/deepwave/ui/login/LoginScreen.kt new file mode 100644 index 0000000..214ff3c --- /dev/null +++ b/app/src/main/java/com/InfernalAquatics/deepwave/ui/login/LoginScreen.kt @@ -0,0 +1,101 @@ +package com.InfernalAquatics.deepwave.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.padding +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel + +@Composable +fun LoginScreen( + onLoginSuccess: () -> Unit, + viewModel: LoginViewModel = hiltViewModel(), +) { + val uiState by viewModel.uiState.collectAsState() + + LaunchedEffect(uiState.loginSucceeded) { + if (uiState.loginSucceeded) onLoginSuccess() + } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text(text = "Deepwave", style = MaterialTheme.typography.headlineMedium) + Text(text = "Connect to your Navidrome server", style = MaterialTheme.typography.bodyMedium) + + Spacer(modifier = Modifier.height(24.dp)) + + OutlinedTextField( + value = uiState.serverUrl, + onValueChange = viewModel::onServerUrlChange, + label = { Text("Server URL") }, + placeholder = { Text("https://navidrome.example.com") }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri), + modifier = Modifier.fillMaxWidth(), + ) + + Spacer(modifier = Modifier.height(12.dp)) + + OutlinedTextField( + value = uiState.username, + onValueChange = viewModel::onUsernameChange, + label = { Text("Username") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + + Spacer(modifier = Modifier.height(12.dp)) + + OutlinedTextField( + value = uiState.password, + onValueChange = viewModel::onPasswordChange, + label = { Text("Password") }, + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + modifier = Modifier.fillMaxWidth(), + ) + + Spacer(modifier = Modifier.height(20.dp)) + + uiState.errorMessage?.let { message -> + Text( + text = message, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + ) + Spacer(modifier = Modifier.height(12.dp)) + } + + if (uiState.isLoading) { + CircularProgressIndicator() + } else { + Button(onClick = viewModel::login, modifier = Modifier.fillMaxWidth()) { + Text("Log in") + } + } + } +} diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/ui/login/LoginViewModel.kt b/app/src/main/java/com/InfernalAquatics/deepwave/ui/login/LoginViewModel.kt new file mode 100644 index 0000000..d069eda --- /dev/null +++ b/app/src/main/java/com/InfernalAquatics/deepwave/ui/login/LoginViewModel.kt @@ -0,0 +1,51 @@ +package com.InfernalAquatics.deepwave.ui.login + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.InfernalAquatics.deepwave.data.repository.LoginResult +import com.InfernalAquatics.deepwave.data.repository.ServerRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +data class LoginUiState( + val serverUrl: String = "", + val username: String = "", + val password: String = "", + val isLoading: Boolean = false, + val errorMessage: String? = null, + val loginSucceeded: Boolean = false, +) + +@HiltViewModel +class LoginViewModel @Inject constructor( + private val serverRepository: ServerRepository, +) : ViewModel() { + + private val _uiState = MutableStateFlow(LoginUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + fun onServerUrlChange(value: String) = _uiState.update { it.copy(serverUrl = value, errorMessage = null) } + fun onUsernameChange(value: String) = _uiState.update { it.copy(username = value, errorMessage = null) } + fun onPasswordChange(value: String) = _uiState.update { it.copy(password = value, errorMessage = null) } + + fun login() { + val state = _uiState.value + if (state.serverUrl.isBlank() || state.username.isBlank() || state.password.isBlank()) { + _uiState.update { it.copy(errorMessage = "Server URL, username, and password are all required") } + return + } + + _uiState.update { it.copy(isLoading = true, errorMessage = null) } + viewModelScope.launch { + when (val result = serverRepository.login(state.serverUrl, state.username, state.password)) { + is LoginResult.Success -> _uiState.update { it.copy(isLoading = false, loginSucceeded = true) } + is LoginResult.Failure -> _uiState.update { it.copy(isLoading = false, errorMessage = result.message) } + } + } + } +} diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/ui/navigation/DeepwaveNavHost.kt b/app/src/main/java/com/InfernalAquatics/deepwave/ui/navigation/DeepwaveNavHost.kt new file mode 100644 index 0000000..2f32e30 --- /dev/null +++ b/app/src/main/java/com/InfernalAquatics/deepwave/ui/navigation/DeepwaveNavHost.kt @@ -0,0 +1,59 @@ +package com.InfernalAquatics.deepwave.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.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.navigation.NavHostController +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import com.InfernalAquatics.deepwave.ui.home.HomeScreen +import com.InfernalAquatics.deepwave.ui.login.LoginScreen + +private object Routes { + const val LOGIN = "login" + const val HOME = "home" +} + +@Composable +fun DeepwaveNavHost( + navController: NavHostController = rememberNavController(), + sessionViewModel: SessionViewModel = hiltViewModel(), +) { + val isLoggedIn by sessionViewModel.isLoggedIn.collectAsState() + + when (isLoggedIn) { + null -> Box(modifier = Modifier.fillMaxSize()) { + CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) + } + else -> NavHost( + navController = navController, + startDestination = if (isLoggedIn == true) Routes.HOME else Routes.LOGIN, + ) { + composable(Routes.LOGIN) { + LoginScreen( + onLoginSuccess = { + navController.navigate(Routes.HOME) { + popUpTo(Routes.LOGIN) { inclusive = true } + } + }, + ) + } + composable(Routes.HOME) { + HomeScreen( + onLoggedOut = { + navController.navigate(Routes.LOGIN) { + popUpTo(Routes.HOME) { inclusive = true } + } + }, + ) + } + } + } +} diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/ui/navigation/SessionViewModel.kt b/app/src/main/java/com/InfernalAquatics/deepwave/ui/navigation/SessionViewModel.kt new file mode 100644 index 0000000..b5d62f2 --- /dev/null +++ b/app/src/main/java/com/InfernalAquatics/deepwave/ui/navigation/SessionViewModel.kt @@ -0,0 +1,19 @@ +package com.InfernalAquatics.deepwave.ui.navigation + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.InfernalAquatics.deepwave.data.repository.ServerRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.stateIn +import javax.inject.Inject + +/** null while the initial session check is in flight. */ +@HiltViewModel +class SessionViewModel @Inject constructor( + serverRepository: ServerRepository, +) : ViewModel() { + val isLoggedIn: StateFlow = serverRepository.isLoggedIn + .stateIn(viewModelScope, SharingStarted.Eagerly, null) +} diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/ui/theme/Color.kt b/app/src/main/java/com/InfernalAquatics/deepwave/ui/theme/Color.kt new file mode 100644 index 0000000..8b8a91e --- /dev/null +++ b/app/src/main/java/com/InfernalAquatics/deepwave/ui/theme/Color.kt @@ -0,0 +1,11 @@ +package com.InfernalAquatics.deepwave.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) \ No newline at end of file diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/ui/theme/Theme.kt b/app/src/main/java/com/InfernalAquatics/deepwave/ui/theme/Theme.kt new file mode 100644 index 0000000..dc301b4 --- /dev/null +++ b/app/src/main/java/com/InfernalAquatics/deepwave/ui/theme/Theme.kt @@ -0,0 +1,58 @@ +package com.InfernalAquatics.deepwave.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 DeepwaveTheme( + 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 + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/ui/theme/Type.kt b/app/src/main/java/com/InfernalAquatics/deepwave/ui/theme/Type.kt new file mode 100644 index 0000000..611bc05 --- /dev/null +++ b/app/src/main/java/com/InfernalAquatics/deepwave/ui/theme/Type.kt @@ -0,0 +1,34 @@ +package com.InfernalAquatics.deepwave.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 + ) + */ +) \ No newline at end of file diff --git a/app/src/main/keepRules/rules.keep b/app/src/main/keepRules/rules.keep new file mode 100644 index 0000000..d7e081a --- /dev/null +++ b/app/src/main/keepRules/rules.keep @@ -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 *; +#} \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..07d5da9 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..2b068d1 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi/ic_launcher.xml b/app/src/main/res/mipmap-anydpi/ic_launcher.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000..c209e78 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000..b2dfe3d Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000..4f0f1d6 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000..62b611d Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000..948a307 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..1b9a695 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000..28d4b77 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9287f50 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000..aa7d642 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9126ae3 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..f8c6127 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..c6afdbe --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + Deepwave + \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..d267c6c --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,5 @@ + + + +