This commit is contained in:
Ultradesu
2026-06-05 12:04:49 +03:00
commit a8e53e344c
90 changed files with 7272 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
/build
+77
View File
@@ -0,0 +1,77 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.compose)
id("com.google.devtools.ksp")
id("com.google.dagger.hilt.android")
}
android {
namespace = "com.example.furumi_android"
compileSdk = 35
defaultConfig {
applicationId = "com.example.furumi_android"
minSdk = 24
targetSdk = 35
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
}
}
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)
// Networking
implementation(libs.retrofit)
implementation(libs.retrofit.moshi)
implementation(libs.okhttp)
implementation(libs.okhttp.logging)
implementation(libs.moshi)
implementation(libs.androidx.media3.exoplayer)
implementation(libs.androidx.media3.datasource.okhttp)
implementation(libs.androidx.media3.session)
// Security & Storage
implementation(libs.androidx.security.crypto)
implementation(libs.androidx.datastore.preferences)
implementation(libs.androidx.browser)
// DI
implementation(libs.hilt.android)
ksp(libs.hilt.compiler)
implementation(libs.hilt.navigation.compose)
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)
}
@@ -0,0 +1,24 @@
package com.example.furumi_android
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.example.furumi_android", appContext.packageName)
}
}
+61
View File
@@ -0,0 +1,61 @@
<?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" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:name=".FurumiApplication"
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:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Furumiandroid"
android:usesCleartextTraffic="true">
<service
android:name=".playback.FurumiPlaybackService"
android:exported="true"
android:foregroundServiceType="mediaPlayback">
<intent-filter>
<action android:name="androidx.media3.session.MediaSessionService" />
<action android:name="android.intent.action.MEDIA_BUTTON" />
</intent-filter>
</service>
<receiver android:name="androidx.media3.session.MediaButtonReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MEDIA_BUTTON" />
</intent-filter>
</receiver>
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name"
android:launchMode="singleTask"
android:theme="@style/Theme.Furumiandroid"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter android:label="Furumi SSO Callback">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="furumi" android:host="auth" android:path="/callback" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -0,0 +1,7 @@
package com.example.furumi_android
import android.app.Application
import dagger.hilt.android.HiltAndroidApp
@HiltAndroidApp
class FurumiApplication : Application()
@@ -0,0 +1,113 @@
package com.example.furumi_android
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.os.Bundle
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import com.example.furumi_android.ui.login.LoginScreen
import com.example.furumi_android.ui.login.LoginViewModel
import com.example.furumi_android.ui.player.PlayerScreen
import com.example.furumi_android.ui.theme.FurumiandroidTheme
import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
private var deepLinkUri by mutableStateOf<Uri?>(null)
private val notificationPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
requestNotificationPermissionIfNeeded()
if (deepLinkUri == null) {
deepLinkUri = intent?.data
}
setContent {
FurumiandroidTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
Box(modifier = Modifier.padding(innerPadding)) {
FurumiApp(
deepLinkUri = deepLinkUri,
onDeepLinkHandled = { deepLinkUri = null }
)
}
}
}
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
deepLinkUri = intent.data
}
private fun requestNotificationPermissionIfNeeded() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return
if (checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
}
@Composable
fun FurumiApp(deepLinkUri: Uri?, onDeepLinkHandled: () -> Unit) {
val navController = rememberNavController()
NavHost(navController = navController, startDestination = "login") {
composable("login") {
val loginViewModel: LoginViewModel = hiltViewModel()
// Handle deep link when it arrives
androidx.compose.runtime.LaunchedEffect(deepLinkUri) {
deepLinkUri?.let { uri ->
loginViewModel.handleDeepLink(uri)
onDeepLinkHandled()
}
}
LoginScreen(
viewModel = loginViewModel,
onLoginSuccess = {
navController.navigate("player") {
popUpTo("login") { inclusive = true }
}
}
)
}
composable("player") {
PlayerScreen(
onLoggedOut = {
navController.navigate("login") {
popUpTo("player") { inclusive = true }
}
}
)
}
}
}
@@ -0,0 +1,117 @@
package com.example.furumi_android.data.local
import android.content.Context
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import com.example.furumi_android.domain.model.AuthSession
import com.example.furumi_android.domain.model.AuthTokens
import com.example.furumi_android.domain.model.User
import com.squareup.moshi.Moshi
class AuthSessionStorage(context: Context, moshi: Moshi) {
private val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
private val prefs = EncryptedSharedPreferences.create(
context,
PREFS_NAME,
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
private val userAdapter = moshi.adapter(User::class.java)
fun saveSession(session: AuthSession) {
prefs.edit()
.putString(KEY_BASE_URL, session.serverBaseUrl)
.putString(KEY_ACCESS_TOKEN, session.tokens.accessToken)
.putString(KEY_REFRESH_TOKEN, session.tokens.refreshToken)
.putString(KEY_TOKEN_TYPE, session.tokens.tokenType)
.putInt(KEY_EXPIRES_IN_SECONDS, session.tokens.expiresInSeconds)
.putLong(KEY_EXPIRES_AT_EPOCH_SECONDS, session.tokens.expiresAtEpochSeconds)
.putString(KEY_USER_DATA, userAdapter.toJson(session.user))
.remove(KEY_PENDING_SSO_BASE_URL)
.apply()
}
fun saveTokens(tokens: AuthTokens) {
prefs.edit()
.putString(KEY_ACCESS_TOKEN, tokens.accessToken)
.putString(KEY_REFRESH_TOKEN, tokens.refreshToken)
.putString(KEY_TOKEN_TYPE, tokens.tokenType)
.putInt(KEY_EXPIRES_IN_SECONDS, tokens.expiresInSeconds)
.putLong(KEY_EXPIRES_AT_EPOCH_SECONDS, tokens.expiresAtEpochSeconds)
.apply()
}
fun savePendingSsoBaseUrl(baseUrl: String) {
prefs.edit()
.putString(KEY_PENDING_SSO_BASE_URL, baseUrl)
.apply()
}
fun getPendingSsoBaseUrl(): String? {
return prefs.getString(KEY_PENDING_SSO_BASE_URL, null)
}
fun getBaseUrl(): String? {
return prefs.getString(KEY_BASE_URL, null)
}
fun getSession(): AuthSession? {
val baseUrl = getBaseUrl() ?: return null
val user = getUser() ?: return null
val tokens = getTokens() ?: return null
return AuthSession(baseUrl, user, tokens)
}
fun getTokens(): AuthTokens? {
val accessToken = prefs.getString(KEY_ACCESS_TOKEN, null) ?: return null
val refreshToken = prefs.getString(KEY_REFRESH_TOKEN, null) ?: return null
val tokenType = prefs.getString(KEY_TOKEN_TYPE, AuthTokens.DEFAULT_TOKEN_TYPE)
?: AuthTokens.DEFAULT_TOKEN_TYPE
return AuthTokens(
accessToken = accessToken,
refreshToken = refreshToken,
tokenType = tokenType,
expiresInSeconds = prefs.getInt(KEY_EXPIRES_IN_SECONDS, 0),
expiresAtEpochSeconds = prefs.getLong(KEY_EXPIRES_AT_EPOCH_SECONDS, 0L)
)
}
private fun getUser(): User? {
val json = prefs.getString(KEY_USER_DATA, null) ?: return null
return runCatching { userAdapter.fromJson(json) }.getOrNull()
}
fun clear() {
prefs.edit().clear().apply()
}
fun clearSession() {
prefs.edit()
.remove(KEY_ACCESS_TOKEN)
.remove(KEY_REFRESH_TOKEN)
.remove(KEY_TOKEN_TYPE)
.remove(KEY_EXPIRES_IN_SECONDS)
.remove(KEY_EXPIRES_AT_EPOCH_SECONDS)
.remove(KEY_PENDING_SSO_BASE_URL)
.remove(KEY_USER_DATA)
.apply()
}
companion object {
private const val PREFS_NAME = "auth_session"
private const val KEY_ACCESS_TOKEN = "access_token"
private const val KEY_REFRESH_TOKEN = "refresh_token"
private const val KEY_TOKEN_TYPE = "token_type"
private const val KEY_EXPIRES_IN_SECONDS = "expires_in_seconds"
private const val KEY_EXPIRES_AT_EPOCH_SECONDS = "expires_at_epoch_seconds"
private const val KEY_BASE_URL = "base_url"
private const val KEY_PENDING_SSO_BASE_URL = "pending_sso_base_url"
private const val KEY_USER_DATA = "user_data"
}
}
@@ -0,0 +1,33 @@
package com.example.furumi_android.data.remote
import com.example.furumi_android.data.local.AuthSessionStorage
import okhttp3.Interceptor
import okhttp3.Response
import javax.inject.Inject
class AccessTokenInterceptor @Inject constructor(
private val sessionStorage: AuthSessionStorage,
private val authEndpoints: AuthEndpoints
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
if (request.header(HEADER_AUTHORIZATION) != null ||
authEndpoints.isLoginOrRefreshPath(request.url.encodedPath)
) {
return chain.proceed(request)
}
val tokens = sessionStorage.getTokens() ?: return chain.proceed(request)
val authorizedRequest = request.newBuilder()
.header(HEADER_AUTHORIZATION, tokens.authorizationHeader)
.build()
return chain.proceed(authorizedRequest)
}
companion object {
const val HEADER_AUTHORIZATION = "Authorization"
}
}
@@ -0,0 +1,31 @@
package com.example.furumi_android.data.remote
import com.example.furumi_android.data.remote.model.ErrorResponse
import com.squareup.moshi.Moshi
import retrofit2.Response
import javax.inject.Inject
class AuthApiErrorParser @Inject constructor(
moshi: Moshi
) {
private val errorAdapter = moshi.adapter(ErrorResponse::class.java)
fun messageFrom(response: Response<*>): String {
val code = response.code()
val rawError = response.errorBody()?.string()
if (rawError.isNullOrBlank()) {
return "Server error $code"
}
val apiError = runCatching { errorAdapter.fromJson(rawError)?.error }
.getOrNull()
?.takeIf { it.isNotBlank() }
return apiError ?: "Server error $code: ${rawError.take(MAX_ERROR_PREVIEW_LENGTH)}"
}
companion object {
private const val MAX_ERROR_PREVIEW_LENGTH = 160
}
}
@@ -0,0 +1,94 @@
package com.example.furumi_android.data.remote
import com.example.furumi_android.data.local.AuthSessionStorage
import com.example.furumi_android.data.remote.AccessTokenInterceptor.Companion.HEADER_AUTHORIZATION
import com.example.furumi_android.data.remote.api.AuthApi
import com.example.furumi_android.data.remote.model.RefreshRequest
import com.example.furumi_android.data.remote.model.toDomain
import dagger.Lazy
import kotlinx.coroutines.runBlocking
import okhttp3.Authenticator
import okhttp3.Request
import okhttp3.Response
import okhttp3.Route
import javax.inject.Inject
class AuthAuthenticator @Inject constructor(
private val sessionStorage: AuthSessionStorage,
private val authEndpoints: AuthEndpoints,
private val authApiLazy: Lazy<AuthApi>
) : Authenticator {
private val refreshLock = Any()
override fun authenticate(route: Route?, response: Response): Request? {
if (responseCount(response) >= MAX_AUTH_ATTEMPTS ||
authEndpoints.isLoginOrRefreshPath(response.request.url.encodedPath)
) {
return null
}
val requestAuthorization = response.request.header(HEADER_AUTHORIZATION) ?: return null
return synchronized(refreshLock) {
val currentTokens = sessionStorage.getTokens() ?: return@synchronized null
if (requestAuthorization != currentTokens.authorizationHeader) {
return@synchronized response.withAuthorization(currentTokens.authorizationHeader)
}
val baseUrl = sessionStorage.getBaseUrl() ?: return@synchronized null
val refreshedTokens = runBlocking {
refreshTokens(baseUrl, currentTokens.refreshToken)
}
if (refreshedTokens == null) {
sessionStorage.clearSession()
null
} else {
sessionStorage.saveTokens(refreshedTokens)
response.withAuthorization(refreshedTokens.authorizationHeader)
}
}
}
private suspend fun refreshTokens(
baseUrl: String,
refreshToken: String
): com.example.furumi_android.domain.model.AuthTokens? {
val refreshResponse = runCatching {
authApiLazy.get().refresh(
url = authEndpoints.refresh(baseUrl),
request = RefreshRequest(refreshToken)
)
}.getOrNull()
if (refreshResponse?.isSuccessful != true) {
return null
}
val body = refreshResponse.body() ?: return null
return body.toDomain(nowEpochSeconds())
}
private fun Response.withAuthorization(authorizationHeader: String): Request {
return request.newBuilder()
.header(HEADER_AUTHORIZATION, authorizationHeader)
.build()
}
private fun responseCount(response: Response): Int {
var count = 1
var priorResponse = response.priorResponse
while (priorResponse != null) {
count++
priorResponse = priorResponse.priorResponse
}
return count
}
private fun nowEpochSeconds(): Long = System.currentTimeMillis() / 1000L
companion object {
private const val MAX_AUTH_ATTEMPTS = 2
}
}
@@ -0,0 +1,67 @@
package com.example.furumi_android.data.remote
import android.net.Uri
import com.example.furumi_android.domain.model.AuthException
import javax.inject.Inject
class AuthEndpoints @Inject constructor() {
fun passwordLogin(baseUrl: String): String = "$baseUrl$PASSWORD_LOGIN_PATH"
fun ssoExchange(baseUrl: String): String = "$baseUrl$SSO_EXCHANGE_PATH"
fun refresh(baseUrl: String): String = "$baseUrl$REFRESH_PATH"
fun logout(baseUrl: String): String = "$baseUrl$LOGOUT_PATH"
fun ssoStart(baseUrl: String): String {
return Uri.parse("$baseUrl$SSO_START_PATH")
.buildUpon()
.appendQueryParameter("redirect_uri", SSO_REDIRECT_URI)
.build()
.toString()
}
fun parseSsoCallback(callbackUrl: String): Result<SsoCallback> {
val uri = runCatching { Uri.parse(callbackUrl) }.getOrElse {
return Result.failure(AuthException("Invalid SSO callback"))
}
if (uri.scheme != SSO_REDIRECT_SCHEME || uri.host != SSO_REDIRECT_HOST || uri.path != SSO_REDIRECT_PATH) {
return Result.failure(AuthException("Unexpected SSO callback"))
}
uri.getQueryParameter("error")?.let { error ->
return Result.failure(AuthException("SSO error: $error"))
}
val code = uri.getQueryParameter("code")
if (code.isNullOrBlank()) {
return Result.failure(AuthException("SSO callback does not contain an authorization code"))
}
return Result.success(SsoCallback(code))
}
fun isLoginOrRefreshPath(path: String): Boolean {
return path.endsWith(PASSWORD_LOGIN_PATH) ||
path.endsWith(SSO_EXCHANGE_PATH) ||
path.endsWith(REFRESH_PATH)
}
companion object {
const val SSO_REDIRECT_URI = "furumi://auth/callback"
private const val PASSWORD_LOGIN_PATH = "/api/auth/password"
private const val SSO_EXCHANGE_PATH = "/api/auth/sso/exchange"
private const val REFRESH_PATH = "/api/auth/refresh"
private const val LOGOUT_PATH = "/api/auth/logout"
private const val SSO_START_PATH = "/auth/mobile/oidc/start"
private const val SSO_REDIRECT_SCHEME = "furumi"
private const val SSO_REDIRECT_HOST = "auth"
private const val SSO_REDIRECT_PATH = "/callback"
}
}
data class SsoCallback(
val code: String
)
@@ -0,0 +1,19 @@
package com.example.furumi_android.data.remote
import javax.inject.Inject
class PlayerEndpoints @Inject constructor() {
fun artists(baseUrl: String): String = "$baseUrl$ARTISTS_PATH"
fun artistDetail(baseUrl: String, artistId: Long): String = "$baseUrl$ARTISTS_PATH/$artistId"
fun releaseDetail(baseUrl: String, releaseId: Long): String = "$baseUrl$RELEASES_PATH/$releaseId"
fun history(baseUrl: String): String = "$baseUrl$HISTORY_PATH"
companion object {
private const val ARTISTS_PATH = "/api/player/artists"
private const val RELEASES_PATH = "/api/player/releases"
private const val HISTORY_PATH = "/api/player/history"
}
}
@@ -0,0 +1,46 @@
package com.example.furumi_android.data.remote.api
import com.example.furumi_android.data.remote.model.LoginRequest
import com.example.furumi_android.data.remote.model.LoginResponse
import com.example.furumi_android.data.remote.model.LogoutRequest
import com.example.furumi_android.data.remote.model.LogoutResponse
import com.example.furumi_android.data.remote.model.RefreshRequest
import com.example.furumi_android.data.remote.model.SsoExchangeRequest
import com.example.furumi_android.data.remote.model.TokenResponse
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.Header
import retrofit2.http.Headers
import retrofit2.http.POST
import retrofit2.http.Url
interface AuthApi {
@Headers("Accept: application/json")
@POST
suspend fun login(
@Url url: String,
@Body request: LoginRequest
): Response<LoginResponse>
@Headers("Accept: application/json")
@POST
suspend fun ssoExchange(
@Url url: String,
@Body request: SsoExchangeRequest
): Response<LoginResponse>
@Headers("Accept: application/json")
@POST
suspend fun refresh(
@Url url: String,
@Body request: RefreshRequest
): Response<TokenResponse>
@Headers("Accept: application/json")
@POST
suspend fun logout(
@Url url: String,
@Header("Authorization") bearerToken: String,
@Body request: LogoutRequest
): Response<LogoutResponse>
}
@@ -0,0 +1,42 @@
package com.example.furumi_android.data.remote.api
import com.example.furumi_android.data.remote.model.ArtistPageResponse
import com.example.furumi_android.data.remote.model.ArtistDetailResponse
import com.example.furumi_android.data.remote.model.PlayHistoryPageResponse
import com.example.furumi_android.data.remote.model.ReleaseDetailResponse
import retrofit2.Response
import retrofit2.http.GET
import retrofit2.http.Headers
import retrofit2.http.Query
import retrofit2.http.Url
interface PlayerApi {
@Headers("Accept: application/json")
@GET
suspend fun artists(
@Url url: String,
@Query("page") page: Int,
@Query("limit") limit: Int,
@Query("mine") mine: Boolean
): Response<ArtistPageResponse>
@Headers("Accept: application/json")
@GET
suspend fun artistDetail(
@Url url: String
): Response<ArtistDetailResponse>
@Headers("Accept: application/json")
@GET
suspend fun releaseDetail(
@Url url: String
): Response<ReleaseDetailResponse>
@Headers("Accept: application/json")
@GET
suspend fun history(
@Url url: String,
@Query("page") page: Int,
@Query("limit") limit: Int
): Response<PlayHistoryPageResponse>
}
@@ -0,0 +1,38 @@
package com.example.furumi_android.data.remote.model
import com.example.furumi_android.domain.model.AuthSession
import com.example.furumi_android.domain.model.AuthTokens
import com.example.furumi_android.domain.model.User
fun LoginResponse.toDomainSession(
serverBaseUrl: String,
receivedAtEpochSeconds: Long
): AuthSession {
return AuthSession(
serverBaseUrl = serverBaseUrl,
user = user.toDomain(),
tokens = tokens.toDomain(receivedAtEpochSeconds)
)
}
fun UserResponse.toDomain(): User {
return User(
id = id,
name = name,
role = role
)
}
fun TokenResponse.toDomain(receivedAtEpochSeconds: Long): AuthTokens {
return AuthTokens(
accessToken = accessToken,
refreshToken = refreshToken,
tokenType = tokenType,
expiresInSeconds = expiresInSeconds,
expiresAtEpochSeconds = if (expiresInSeconds > 0) {
receivedAtEpochSeconds + expiresInSeconds
} else {
0L
}
)
}
@@ -0,0 +1,26 @@
package com.example.furumi_android.data.remote.model
import com.squareup.moshi.Json
data class LoginRequest(
@param:Json(name = "username") val username: String,
@param:Json(name = "password") val password: String,
@param:Json(name = "device_name") val deviceName: String
)
data class SsoExchangeRequest(
@param:Json(name = "code") val code: String,
@param:Json(name = "device_name") val deviceName: String
)
data class RefreshRequest(
@param:Json(name = "refresh_token") val refreshToken: String
)
data class LogoutRequest(
@param:Json(name = "refresh_token") val refreshToken: String
)
data class LogoutResponse(
@param:Json(name = "revoked") val revoked: Boolean
)
@@ -0,0 +1,25 @@
package com.example.furumi_android.data.remote.model
import com.squareup.moshi.Json
data class LoginResponse(
@param:Json(name = "user") val user: UserResponse,
@param:Json(name = "tokens") val tokens: TokenResponse
)
data class UserResponse(
@param:Json(name = "id") val id: Int,
@param:Json(name = "name") val name: String,
@param:Json(name = "role") val role: String
)
data class TokenResponse(
@param:Json(name = "access_token") val accessToken: String,
@param:Json(name = "refresh_token") val refreshToken: String,
@param:Json(name = "token_type") val tokenType: String,
@param:Json(name = "expires_in_seconds") val expiresInSeconds: Int
)
data class ErrorResponse(
@param:Json(name = "error") val error: String
)
@@ -0,0 +1,224 @@
package com.example.furumi_android.data.remote.model
import com.example.furumi_android.domain.model.ArtistCard
import com.example.furumi_android.domain.model.ArtistDetail
import com.example.furumi_android.domain.model.ArtistPage
import com.example.furumi_android.domain.model.ListeningHistoryItem
import com.example.furumi_android.domain.model.ListeningHistoryPage
import com.example.furumi_android.domain.model.ReleaseCard
import com.example.furumi_android.domain.model.ReleaseDetail
import com.example.furumi_android.domain.model.TrackCard
import com.squareup.moshi.Json
data class ArtistRefResponse(
@param:Json(name = "id") val id: Long,
@param:Json(name = "name") val name: String
)
data class TrackItemResponse(
@param:Json(name = "id") val id: Long,
@param:Json(name = "title") val title: String,
@param:Json(name = "track_number") val trackNumber: Int? = null,
@param:Json(name = "disc_number") val discNumber: Int? = null,
@param:Json(name = "duration_seconds") val durationSeconds: Double,
@param:Json(name = "artists") val artists: List<ArtistRefResponse> = emptyList(),
@param:Json(name = "featured_artists") val featuredArtists: List<ArtistRefResponse> = emptyList(),
@param:Json(name = "release_title") val releaseTitle: String? = null,
@param:Json(name = "cover_url") val coverUrl: String? = null,
@param:Json(name = "stream_url") val streamUrl: String
)
data class PlayHistoryItemResponse(
@param:Json(name = "id") val id: Long,
@param:Json(name = "track_id") val trackId: Long,
@param:Json(name = "track_title") val trackTitle: String,
@param:Json(name = "release_title") val releaseTitle: String? = null,
@param:Json(name = "track") val track: TrackItemResponse,
@param:Json(name = "played_at") val playedAt: String,
@param:Json(name = "duration_listened") val durationListened: Double? = null,
@param:Json(name = "completed") val completed: Boolean
)
data class PlayHistoryPageResponse(
@param:Json(name = "items") val items: List<PlayHistoryItemResponse>,
@param:Json(name = "total") val total: Long,
@param:Json(name = "page") val page: Int,
@param:Json(name = "per_page") val perPage: Int
)
data class ArtistCardResponse(
@param:Json(name = "id") val id: Long,
@param:Json(name = "name") val name: String,
@param:Json(name = "image_url") val imageUrl: String? = null,
@param:Json(name = "release_count") val releaseCount: Int = 0,
@param:Json(name = "track_count") val trackCount: Int = 0
)
data class ArtistPageResponse(
@param:Json(name = "items") val items: List<ArtistCardResponse>,
@param:Json(name = "total") val total: Long,
@param:Json(name = "page") val page: Int,
@param:Json(name = "per_page") val perPage: Int,
@param:Json(name = "has_more") val hasMore: Boolean
)
data class ReleaseCardResponse(
@param:Json(name = "id") val id: Long,
@param:Json(name = "title") val title: String,
@param:Json(name = "release_type") val releaseType: String? = null,
@param:Json(name = "year") val year: Int? = null,
@param:Json(name = "cover_url") val coverUrl: String? = null,
@param:Json(name = "track_count") val trackCount: Int = 0
)
data class ArtistDetailResponse(
@param:Json(name = "artist") val artist: ArtistCardResponse? = null,
@param:Json(name = "id") val id: Long? = null,
@param:Json(name = "name") val name: String? = null,
@param:Json(name = "image_url") val imageUrl: String? = null,
@param:Json(name = "release_count") val releaseCount: Int? = null,
@param:Json(name = "track_count") val trackCount: Int? = null,
@param:Json(name = "top_tracks") val topTracks: List<TrackItemResponse> = emptyList(),
@param:Json(name = "releases") val releases: List<ReleaseCardResponse> = emptyList(),
@param:Json(name = "featured_tracks") val featuredTracks: List<TrackItemResponse> = emptyList()
)
data class ReleaseDetailResponse(
@param:Json(name = "release") val release: ReleaseCardResponse? = null,
@param:Json(name = "id") val id: Long? = null,
@param:Json(name = "title") val title: String? = null,
@param:Json(name = "release_type") val releaseType: String? = null,
@param:Json(name = "year") val year: Int? = null,
@param:Json(name = "cover_url") val coverUrl: String? = null,
@param:Json(name = "track_count") val trackCount: Int? = null,
@param:Json(name = "artists") val artists: List<ArtistCardResponse> = emptyList(),
@param:Json(name = "tracks") val tracks: List<TrackItemResponse> = emptyList()
)
fun PlayHistoryPageResponse.toDomain(): ListeningHistoryPage {
return ListeningHistoryPage(
items = items.map { it.toDomain() },
total = total,
page = page,
perPage = perPage
)
}
private fun PlayHistoryItemResponse.toDomain(): ListeningHistoryItem {
val artistNames = track.artists
.ifEmpty { track.featuredArtists }
.map { it.name }
return ListeningHistoryItem(
id = id,
trackId = trackId,
title = trackTitle.ifBlank { track.title },
artists = artistNames,
releaseTitle = releaseTitle ?: track.releaseTitle,
playedAt = playedAt,
durationListenedSeconds = durationListened?.toInt(),
completed = completed
)
}
fun ArtistPageResponse.toDomain(baseUrl: String): ArtistPage {
return ArtistPage(
items = items.map { it.toDomain(baseUrl) },
total = total,
page = page,
perPage = perPage,
hasMore = hasMore
)
}
fun ArtistDetailResponse.toDomain(baseUrl: String): ArtistDetail {
val mappedReleases = releases.map { it.toDomain(baseUrl) }
val mappedTopTracks = topTracks.map { it.toDomain(baseUrl) }
val mappedFeaturedTracks = featuredTracks.map { it.toDomain(baseUrl) }
val mappedArtist = artist?.toDomain(baseUrl) ?: ArtistCard(
id = id ?: 0L,
name = name ?: "Unknown artist",
imageUrl = imageUrl.toAbsoluteMediaUrl(baseUrl),
releaseCount = releaseCount ?: mappedReleases.size,
trackCount = trackCount ?: (mappedTopTracks.size + mappedFeaturedTracks.size)
)
return ArtistDetail(
artist = mappedArtist,
releases = mappedReleases,
topTracks = mappedTopTracks,
featuredTracks = mappedFeaturedTracks
)
}
fun ReleaseDetailResponse.toDomain(baseUrl: String): ReleaseDetail {
val mappedTracks = tracks.map { it.toDomain(baseUrl) }
val mappedRelease = release?.toDomain(baseUrl) ?: ReleaseCard(
id = id ?: 0L,
title = title ?: "Unknown release",
releaseType = releaseType,
year = year,
coverUrl = coverUrl.toAbsoluteMediaUrl(baseUrl),
trackCount = trackCount ?: mappedTracks.size
)
return ReleaseDetail(
release = mappedRelease,
artists = artists.map { it.toDomain(baseUrl) },
tracks = mappedTracks
)
}
private fun ArtistCardResponse.toDomain(baseUrl: String): ArtistCard {
return ArtistCard(
id = id,
name = name,
imageUrl = imageUrl.toAbsoluteMediaUrl(baseUrl),
releaseCount = releaseCount,
trackCount = trackCount
)
}
private fun ReleaseCardResponse.toDomain(baseUrl: String): ReleaseCard {
return ReleaseCard(
id = id,
title = title,
releaseType = releaseType,
year = year,
coverUrl = coverUrl.toAbsoluteMediaUrl(baseUrl),
trackCount = trackCount
)
}
private fun TrackItemResponse.toDomain(baseUrl: String): TrackCard {
val artistNames = artists
.ifEmpty { featuredArtists }
.map { it.name }
return TrackCard(
id = id,
title = title,
trackNumber = trackNumber,
discNumber = discNumber,
durationSeconds = durationSeconds.toInt(),
artists = artistNames,
releaseTitle = releaseTitle,
coverUrl = coverUrl.toAbsoluteMediaUrl(baseUrl),
streamUrl = streamUrl.toAbsoluteMediaUrl(baseUrl).orEmpty()
)
}
private fun String?.toAbsoluteMediaUrl(baseUrl: String): String? {
val value = this?.trim()?.takeIf { it.isNotBlank() } ?: return null
if (value.startsWith("http://", ignoreCase = true) ||
value.startsWith("https://", ignoreCase = true)
) {
return value
}
val normalizedBaseUrl = baseUrl.trimEnd('/')
val normalizedPath = if (value.startsWith('/')) value else "/$value"
return "$normalizedBaseUrl$normalizedPath"
}
@@ -0,0 +1,23 @@
package com.example.furumi_android.data.repository
import android.os.Build
import javax.inject.Inject
class AuthDeviceInfo @Inject constructor() {
val deviceName: String
get() {
val manufacturer = Build.MANUFACTURER.orEmpty().trim()
val model = Build.MODEL.orEmpty().trim()
return when {
model.isBlank() -> DEFAULT_DEVICE_NAME
manufacturer.isBlank() -> model
model.startsWith(manufacturer, ignoreCase = true) -> model
else -> "$manufacturer $model"
}
}
companion object {
private const val DEFAULT_DEVICE_NAME = "Android Device"
}
}
@@ -0,0 +1,123 @@
package com.example.furumi_android.data.repository
import com.example.furumi_android.data.local.AuthSessionStorage
import com.example.furumi_android.data.remote.AuthApiErrorParser
import com.example.furumi_android.data.remote.AuthEndpoints
import com.example.furumi_android.data.remote.api.AuthApi
import com.example.furumi_android.data.remote.model.LoginRequest
import com.example.furumi_android.data.remote.model.LoginResponse
import com.example.furumi_android.data.remote.model.LogoutRequest
import com.example.furumi_android.data.remote.model.SsoExchangeRequest
import com.example.furumi_android.data.remote.model.toDomainSession
import com.example.furumi_android.domain.model.AuthException
import com.example.furumi_android.domain.model.AuthSession
import com.example.furumi_android.domain.model.ServerConfig
import com.example.furumi_android.domain.repository.AuthRepository
import kotlinx.coroutines.CancellationException
import retrofit2.Response
import javax.inject.Inject
class AuthRepositoryImpl @Inject constructor(
private val authApi: AuthApi,
private val sessionStorage: AuthSessionStorage,
private val authEndpoints: AuthEndpoints,
private val errorParser: AuthApiErrorParser,
private val deviceInfo: AuthDeviceInfo
) : AuthRepository {
override suspend fun login(config: ServerConfig, password: String): Result<AuthSession> {
return authResult {
val normalizedConfig = ServerConfig.credentials(config.baseUrl, config.login).getOrThrow()
val response = authApi.login(
url = authEndpoints.passwordLogin(normalizedConfig.baseUrl),
request = LoginRequest(
username = normalizedConfig.login,
password = password,
deviceName = deviceInfo.deviceName
)
)
saveAuthSession(response, normalizedConfig.baseUrl)
}
}
override suspend fun createSsoAuthorizationUrl(serverUrl: String): Result<String> {
return authResult {
val config = ServerConfig.sso(serverUrl).getOrThrow()
sessionStorage.savePendingSsoBaseUrl(config.baseUrl)
authEndpoints.ssoStart(config.baseUrl)
}
}
override suspend fun completeSsoLogin(callbackUrl: String): Result<AuthSession> {
return authResult {
val callback = authEndpoints.parseSsoCallback(callbackUrl).getOrThrow()
val baseUrl = sessionStorage.getPendingSsoBaseUrl()
?: sessionStorage.getBaseUrl()
?: throw AuthException("SSO server is missing. Start SSO login again.")
val response = authApi.ssoExchange(
url = authEndpoints.ssoExchange(baseUrl),
request = SsoExchangeRequest(
code = callback.code,
deviceName = deviceInfo.deviceName
)
)
saveAuthSession(response, baseUrl)
}
}
private fun saveAuthSession(response: Response<LoginResponse>, baseUrl: String): AuthSession {
if (!response.isSuccessful) {
throw AuthException(errorParser.messageFrom(response))
}
val body = response.body() ?: throw AuthException("Auth response is empty")
val session = body.toDomainSession(
serverBaseUrl = baseUrl,
receivedAtEpochSeconds = nowEpochSeconds()
)
sessionStorage.saveSession(session)
return session
}
override suspend fun logout(): Result<Unit> {
return try {
val session = sessionStorage.getSession()
if (session != null) {
authApi.logout(
url = authEndpoints.logout(session.serverBaseUrl),
bearerToken = session.tokens.authorizationHeader,
request = LogoutRequest(session.tokens.refreshToken)
)
}
Result.success(Unit)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Result.failure(AuthException("Logged out locally, but server logout failed", e))
} finally {
sessionStorage.clearSession()
}
}
override fun getCurrentSession(): AuthSession? {
return sessionStorage.getSession()
}
override fun getSavedServerUrl(): String? {
return sessionStorage.getBaseUrl() ?: sessionStorage.getPendingSsoBaseUrl()
}
private suspend fun <T> authResult(block: suspend () -> T): Result<T> {
return try {
Result.success(block())
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Result.failure(e)
}
}
private fun nowEpochSeconds(): Long {
return System.currentTimeMillis() / 1000L
}
}
@@ -0,0 +1,38 @@
package com.example.furumi_android.data.repository
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import java.io.IOException
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
@Singleton
class MediaImageLoader @Inject constructor(
private val okHttpClient: OkHttpClient
) {
suspend fun load(url: String): Result<Bitmap> {
return withContext(Dispatchers.IO) {
runCatching {
val request = Request.Builder()
.url(url)
.build()
okHttpClient.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
throw IOException("Image request failed with HTTP ${response.code}")
}
val bytes = response.body?.bytes()
?: throw IOException("Image response is empty")
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
?: throw IOException("Image response is not a supported bitmap")
}
}
}
}
}
@@ -0,0 +1,112 @@
package com.example.furumi_android.data.repository
import com.example.furumi_android.data.local.AuthSessionStorage
import com.example.furumi_android.data.remote.AuthApiErrorParser
import com.example.furumi_android.data.remote.PlayerEndpoints
import com.example.furumi_android.data.remote.api.PlayerApi
import com.example.furumi_android.data.remote.model.toDomain
import com.example.furumi_android.domain.model.ArtistDetail
import com.example.furumi_android.domain.model.ArtistPage
import com.example.furumi_android.domain.model.AuthException
import com.example.furumi_android.domain.model.ListeningHistoryPage
import com.example.furumi_android.domain.model.ReleaseDetail
import com.example.furumi_android.domain.repository.PlayerRepository
import kotlinx.coroutines.CancellationException
import javax.inject.Inject
class PlayerRepositoryImpl @Inject constructor(
private val playerApi: PlayerApi,
private val sessionStorage: AuthSessionStorage,
private val playerEndpoints: PlayerEndpoints,
private val errorParser: AuthApiErrorParser
) : PlayerRepository {
override suspend fun getArtists(page: Int, limit: Int, mine: Boolean): Result<ArtistPage> {
return try {
val baseUrl = sessionStorage.getBaseUrl()
?: throw AuthException("Server URL is missing")
val response = playerApi.artists(
url = playerEndpoints.artists(baseUrl),
page = page,
limit = limit,
mine = mine
)
if (!response.isSuccessful) {
throw AuthException(errorParser.messageFrom(response))
}
val body = response.body() ?: throw AuthException("Artists response is empty")
Result.success(body.toDomain(baseUrl))
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Result.failure(e)
}
}
override suspend fun getArtistDetail(artistId: Long): Result<ArtistDetail> {
return try {
val baseUrl = sessionStorage.getBaseUrl()
?: throw AuthException("Server URL is missing")
val response = playerApi.artistDetail(
url = playerEndpoints.artistDetail(baseUrl, artistId)
)
if (!response.isSuccessful) {
throw AuthException(errorParser.messageFrom(response))
}
val body = response.body() ?: throw AuthException("Artist response is empty")
Result.success(body.toDomain(baseUrl))
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Result.failure(e)
}
}
override suspend fun getReleaseDetail(releaseId: Long): Result<ReleaseDetail> {
return try {
val baseUrl = sessionStorage.getBaseUrl()
?: throw AuthException("Server URL is missing")
val response = playerApi.releaseDetail(
url = playerEndpoints.releaseDetail(baseUrl, releaseId)
)
if (!response.isSuccessful) {
throw AuthException(errorParser.messageFrom(response))
}
val body = response.body() ?: throw AuthException("Release response is empty")
Result.success(body.toDomain(baseUrl))
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Result.failure(e)
}
}
override suspend fun getListeningHistory(page: Int, limit: Int): Result<ListeningHistoryPage> {
return try {
val baseUrl = sessionStorage.getBaseUrl()
?: throw AuthException("Server URL is missing")
val response = playerApi.history(
url = playerEndpoints.history(baseUrl),
page = page,
limit = limit
)
if (!response.isSuccessful) {
throw AuthException(errorParser.messageFrom(response))
}
val body = response.body() ?: throw AuthException("Listening history response is empty")
Result.success(body.toDomain())
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Result.failure(e)
}
}
}
@@ -0,0 +1,90 @@
package com.example.furumi_android.di
import com.example.furumi_android.data.local.AuthSessionStorage
import com.example.furumi_android.data.remote.AccessTokenInterceptor
import com.example.furumi_android.data.remote.AuthAuthenticator
import com.example.furumi_android.data.remote.AuthEndpoints
import com.example.furumi_android.data.remote.api.AuthApi
import com.example.furumi_android.data.remote.api.PlayerApi
import com.squareup.moshi.Moshi
import dagger.Lazy
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
@Provides
@Singleton
fun provideAccessTokenInterceptor(
sessionStorage: AuthSessionStorage,
authEndpoints: AuthEndpoints
): AccessTokenInterceptor {
return AccessTokenInterceptor(sessionStorage, authEndpoints)
}
@Provides
@Singleton
fun provideAuthAuthenticator(
sessionStorage: AuthSessionStorage,
authEndpoints: AuthEndpoints,
authApiLazy: Lazy<AuthApi>
): AuthAuthenticator {
return AuthAuthenticator(sessionStorage, authEndpoints, authApiLazy)
}
@Provides
@Singleton
fun provideOkHttpClient(
accessTokenInterceptor: AccessTokenInterceptor,
authAuthenticator: AuthAuthenticator
): OkHttpClient {
val logging = HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BASIC
}
return OkHttpClient.Builder()
.addInterceptor(accessTokenInterceptor)
.addInterceptor(logging)
.authenticator(authAuthenticator)
.build()
}
@Provides
@Singleton
fun provideAuthApi(
okHttpClient: OkHttpClient,
moshi: Moshi
): AuthApi {
// Auth requests use @Url, but Retrofit still requires an initial base URL.
// The actual server URL is supplied per request.
return Retrofit.Builder()
.baseUrl("https://placeholder.com/")
.client(okHttpClient)
.addConverterFactory(MoshiConverterFactory.create(moshi))
.build()
.create(AuthApi::class.java)
}
@Provides
@Singleton
fun providePlayerApi(
okHttpClient: OkHttpClient,
moshi: Moshi
): PlayerApi {
return Retrofit.Builder()
.baseUrl("https://placeholder.com/")
.client(okHttpClient)
.addConverterFactory(MoshiConverterFactory.create(moshi))
.build()
.create(PlayerApi::class.java)
}
}
@@ -0,0 +1,28 @@
package com.example.furumi_android.di
import com.example.furumi_android.data.repository.AuthRepositoryImpl
import com.example.furumi_android.data.repository.PlayerRepositoryImpl
import com.example.furumi_android.domain.repository.AuthRepository
import com.example.furumi_android.domain.repository.PlayerRepository
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {
@Binds
@Singleton
abstract fun bindAuthRepository(
impl: AuthRepositoryImpl
): AuthRepository
@Binds
@Singleton
abstract fun bindPlayerRepository(
impl: PlayerRepositoryImpl
): PlayerRepository
}
@@ -0,0 +1,34 @@
package com.example.furumi_android.di
import android.content.Context
import com.example.furumi_android.data.local.AuthSessionStorage
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object StorageModule {
@Provides
@Singleton
fun provideMoshi(): Moshi {
return Moshi.Builder()
.add(KotlinJsonAdapterFactory())
.build()
}
@Provides
@Singleton
fun provideAuthSessionStorage(
@ApplicationContext context: Context,
moshi: Moshi
): AuthSessionStorage {
return AuthSessionStorage(context, moshi)
}
}
@@ -0,0 +1,51 @@
package com.example.furumi_android.domain.model
data class ArtistCard(
val id: Long,
val name: String,
val imageUrl: String?,
val releaseCount: Int,
val trackCount: Int
)
data class ArtistPage(
val items: List<ArtistCard>,
val total: Long,
val page: Int,
val perPage: Int,
val hasMore: Boolean
)
data class ReleaseCard(
val id: Long,
val title: String,
val releaseType: String?,
val year: Int?,
val coverUrl: String?,
val trackCount: Int
)
data class TrackCard(
val id: Long,
val title: String,
val trackNumber: Int?,
val discNumber: Int?,
val durationSeconds: Int?,
val artists: List<String>,
val releaseTitle: String?,
val coverUrl: String?,
val streamUrl: String
)
data class ArtistDetail(
val artist: ArtistCard,
val releases: List<ReleaseCard>,
val topTracks: List<TrackCard>,
val featuredTracks: List<TrackCard>
)
data class ReleaseDetail(
val release: ReleaseCard,
val artists: List<ArtistCard>,
val tracks: List<TrackCard>
)
@@ -0,0 +1,6 @@
package com.example.furumi_android.domain.model
class AuthException(
message: String,
cause: Throwable? = null
) : Exception(message, cause)
@@ -0,0 +1,7 @@
package com.example.furumi_android.domain.model
data class AuthSession(
val serverBaseUrl: String,
val user: User,
val tokens: AuthTokens
)
@@ -0,0 +1,21 @@
package com.example.furumi_android.domain.model
data class AuthTokens(
val accessToken: String,
val refreshToken: String,
val tokenType: String = DEFAULT_TOKEN_TYPE,
val expiresInSeconds: Int,
val expiresAtEpochSeconds: Long = 0L
) {
val authorizationHeader: String
get() = "${tokenType.ifBlank { DEFAULT_TOKEN_TYPE }} $accessToken"
fun isExpired(nowEpochSeconds: Long, skewSeconds: Long = DEFAULT_EXPIRY_SKEW_SECONDS): Boolean {
return expiresAtEpochSeconds > 0 && nowEpochSeconds >= expiresAtEpochSeconds - skewSeconds
}
companion object {
const val DEFAULT_TOKEN_TYPE = "Bearer"
private const val DEFAULT_EXPIRY_SKEW_SECONDS = 60L
}
}
@@ -0,0 +1,19 @@
package com.example.furumi_android.domain.model
data class ListeningHistoryPage(
val items: List<ListeningHistoryItem>,
val total: Long,
val page: Int,
val perPage: Int
)
data class ListeningHistoryItem(
val id: Long,
val trackId: Long,
val title: String,
val artists: List<String>,
val releaseTitle: String?,
val playedAt: String,
val durationListenedSeconds: Int?,
val completed: Boolean
)
@@ -0,0 +1,74 @@
package com.example.furumi_android.domain.model
import java.net.URI
import java.util.Locale
data class ServerConfig(
val baseUrl: String,
val login: String = ""
) {
val url: String
get() = baseUrl
companion object {
fun credentials(serverUrl: String, login: String): Result<ServerConfig> {
val normalizedBaseUrl = normalizeBaseUrl(serverUrl).getOrElse { return Result.failure(it) }
val trimmedLogin = login.trim()
if (trimmedLogin.isBlank()) {
return Result.failure(IllegalArgumentException("Username is required"))
}
return Result.success(ServerConfig(normalizedBaseUrl, trimmedLogin))
}
fun sso(serverUrl: String): Result<ServerConfig> {
val normalizedBaseUrl = normalizeBaseUrl(serverUrl).getOrElse { return Result.failure(it) }
return Result.success(ServerConfig(normalizedBaseUrl))
}
fun normalizeBaseUrl(rawUrl: String): Result<String> {
val trimmedUrl = rawUrl.trim().trimEnd('/')
if (trimmedUrl.isBlank()) {
return Result.failure(IllegalArgumentException("Server URL is required"))
}
val candidate = if (trimmedUrl.hasScheme()) trimmedUrl else "https://$trimmedUrl"
val uri = runCatching { URI(candidate) }.getOrElse {
return Result.failure(IllegalArgumentException("Enter a valid server URL"))
}
val scheme = uri.scheme?.lowercase(Locale.US)
val rawAuthority = uri.rawAuthority
val host = uri.host
if (scheme != "http" && scheme != "https") {
return Result.failure(IllegalArgumentException("Server URL must start with http:// or https://"))
}
if (rawAuthority.isNullOrBlank() || host.isNullOrBlank()) {
return Result.failure(IllegalArgumentException("Enter a valid server URL"))
}
if (uri.userInfo != null) {
return Result.failure(IllegalArgumentException("Server URL must not contain user info"))
}
if (uri.rawQuery != null || uri.rawFragment != null) {
return Result.failure(IllegalArgumentException("Server URL must not contain query or fragment"))
}
val normalizedPath = uri.rawPath
?.takeUnless { it == "/" }
?.trimEnd('/')
.orEmpty()
val authority = rawAuthority.lowercase(Locale.US)
return Result.success("$scheme://$authority$normalizedPath")
}
private fun String.hasScheme(): Boolean {
return startsWith("http://", ignoreCase = true) || startsWith("https://", ignoreCase = true)
}
}
}
@@ -0,0 +1,7 @@
package com.example.furumi_android.domain.model
data class User(
val id: Int,
val name: String,
val role: String
)
@@ -0,0 +1,18 @@
package com.example.furumi_android.domain.repository
import com.example.furumi_android.domain.model.AuthSession
import com.example.furumi_android.domain.model.ServerConfig
import com.example.furumi_android.domain.model.User
interface AuthRepository {
suspend fun login(config: ServerConfig, password: String): Result<AuthSession>
suspend fun createSsoAuthorizationUrl(serverUrl: String): Result<String>
suspend fun completeSsoLogin(callbackUrl: String): Result<AuthSession>
suspend fun logout(): Result<Unit>
fun getCurrentSession(): AuthSession?
fun getSavedServerUrl(): String?
fun getCurrentUser(): User? {
return getCurrentSession()?.user
}
}
@@ -0,0 +1,20 @@
package com.example.furumi_android.domain.repository
import com.example.furumi_android.domain.model.ArtistPage
import com.example.furumi_android.domain.model.ArtistDetail
import com.example.furumi_android.domain.model.ListeningHistoryPage
import com.example.furumi_android.domain.model.ReleaseDetail
interface PlayerRepository {
suspend fun getArtists(
page: Int = 1,
limit: Int = 60,
mine: Boolean = false
): Result<ArtistPage>
suspend fun getArtistDetail(artistId: Long): Result<ArtistDetail>
suspend fun getReleaseDetail(releaseId: Long): Result<ReleaseDetail>
suspend fun getListeningHistory(page: Int = 1, limit: Int = 20): Result<ListeningHistoryPage>
}
@@ -0,0 +1,126 @@
package com.example.furumi_android.playback
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.util.Log
import androidx.annotation.OptIn
import androidx.core.app.NotificationCompat
import androidx.media3.common.util.UnstableApi
import androidx.media3.session.DefaultMediaNotificationProvider
import androidx.media3.session.MediaSession
import androidx.media3.session.MediaSessionService
import androidx.media3.session.MediaStyleNotificationHelper
import com.example.furumi_android.MainActivity
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
@AndroidEntryPoint
class FurumiPlaybackService : MediaSessionService() {
@Inject lateinit var playbackController: PlaybackController
private var mediaSession: MediaSession? = null
companion object {
const val NOTIFICATION_ID = 1001
const val CHANNEL_ID = "furumi_playback_channel"
}
@OptIn(UnstableApi::class)
override fun onCreate() {
super.onCreate()
Log.d("FurumiPlaybackService", "onCreate")
createNotificationChannel()
val sessionActivity = PendingIntent.getActivity(
this,
0,
Intent(this, MainActivity::class.java),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
mediaSession = MediaSession.Builder(this, playbackController.player)
.setId("FurumiPlaybackSession")
.setSessionActivity(sessionActivity)
.build()
// Настройка провайдера с иконкой
val notificationProvider = DefaultMediaNotificationProvider.Builder(this)
.setChannelId(CHANNEL_ID)
.setNotificationId(NOTIFICATION_ID)
.build()
// Это важно для появления иконки в статус-баре при автоматических обновлениях Media3
notificationProvider.setSmallIcon(android.R.drawable.ic_media_play)
setMediaNotificationProvider(notificationProvider)
}
@OptIn(UnstableApi::class)
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val session = mediaSession
if (session != null) {
val metadata = playbackController.player.currentMediaItem?.mediaMetadata
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(android.R.drawable.ic_media_play) // Иконка для трея
.setContentTitle(metadata?.title ?: "Furumi")
.setContentText(metadata?.artist ?: "Playing...")
.setOngoing(true)
.setCategory(NotificationCompat.CATEGORY_TRANSPORT) // Сообщаем системе, что это транспорт управления
.setPriority(NotificationCompat.PRIORITY_MAX) // Максимальный приоритет для трея
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setStyle(MediaStyleNotificationHelper.MediaStyle(session))
.build()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK)
} else {
startForeground(NOTIFICATION_ID, notification)
}
}
super.onStartCommand(intent, flags, startId)
return START_STICKY
}
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? {
return mediaSession
}
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val name = "Furumi Playback"
// Повышаем важность до HIGH, чтобы иконка не пропадала
val importance = NotificationManager.IMPORTANCE_HIGH
val channel = NotificationChannel(CHANNEL_ID, name, importance).apply {
setShowBadge(true)
lockscreenVisibility = android.app.Notification.VISIBILITY_PUBLIC
}
val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.createNotificationChannel(channel)
}
}
@OptIn(UnstableApi::class)
override fun onTaskRemoved(rootIntent: Intent?) {
val player = playbackController.player
if (!player.playWhenReady || player.mediaItemCount == 0) {
stopSelf()
}
}
override fun onDestroy() {
Log.d("FurumiPlaybackService", "onDestroy")
mediaSession?.run {
release()
mediaSession = null
}
super.onDestroy()
}
}
@@ -0,0 +1,263 @@
package com.example.furumi_android.playback
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.net.Uri
import androidx.annotation.OptIn
import androidx.media3.common.AudioAttributes
import androidx.media3.common.C
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.core.content.ContextCompat
import androidx.media3.datasource.okhttp.OkHttpDataSource
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import com.example.furumi_android.data.repository.MediaImageLoader
import com.example.furumi_android.domain.model.TrackCard
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import okhttp3.OkHttpClient
import java.io.ByteArrayOutputStream
data class AudioPlaybackState(
val queue: List<TrackCard> = emptyList(),
val currentIndex: Int = -1,
val isPlaying: Boolean = false,
val isBuffering: Boolean = false,
val positionMs: Long = 0L,
val durationMs: Long = 0L,
val errorMessage: String? = null
) {
val currentTrack: TrackCard?
get() = queue.getOrNull(currentIndex)
val progress: Float
get() = if (durationMs > 0L) {
(positionMs.toFloat() / durationMs.toFloat()).coerceIn(0f, 1f)
} else {
0f
}
val canGoPrevious: Boolean
get() = currentIndex > 0 || positionMs > RESTART_WINDOW_MS
val canGoNext: Boolean
get() = currentIndex in 0 until queue.lastIndex
private companion object {
const val RESTART_WINDOW_MS = 3_000L
}
}
@OptIn(UnstableApi::class)
@Singleton
class PlaybackController @Inject constructor(
@param:ApplicationContext private val context: Context,
private val mediaImageLoader: MediaImageLoader,
okHttpClient: OkHttpClient
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
private val _state = MutableStateFlow(AudioPlaybackState())
val player: ExoPlayer
val state: StateFlow<AudioPlaybackState> = _state.asStateFlow()
init {
val dataSourceFactory = OkHttpDataSource.Factory(okHttpClient)
val mediaSourceFactory = DefaultMediaSourceFactory(dataSourceFactory)
player = ExoPlayer.Builder(context)
.setMediaSourceFactory(mediaSourceFactory)
.build()
.apply {
setAudioAttributes(
AudioAttributes.Builder()
.setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
.setUsage(C.USAGE_MEDIA)
.build(),
true
)
setHandleAudioBecomingNoisy(true)
setWakeMode(C.WAKE_MODE_LOCAL)
}
player.addListener(
object : Player.Listener {
override fun onIsPlayingChanged(isPlaying: Boolean) {
publishState()
}
override fun onPlaybackStateChanged(playbackState: Int) {
publishState()
}
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
publishState()
// Просто уведомляем систему, что состояние изменилось.
// MediaSessionService сам обновит уведомление, так как он слушает плеер.
}
override fun onPlayerError(error: PlaybackException) {
_state.value = _state.value.copy(
isPlaying = false,
isBuffering = false,
errorMessage = error.message ?: "Playback failed"
)
}
}
)
scope.launch {
while (isActive) {
publishState()
delay(PROGRESS_TICK_MS)
}
}
}
fun playQueue(queue: List<TrackCard>, startIndex: Int = 0) {
val playableQueue = queue.filter { it.streamUrl.isNotBlank() }
if (playableQueue.isEmpty()) {
_state.value = AudioPlaybackState(errorMessage = "No playable tracks")
return
}
val safeStartIndex = startIndex.coerceIn(0, playableQueue.lastIndex)
val mediaItems = playableQueue.map { track ->
MediaItem.Builder()
.setMediaId(track.id.toString())
.setUri(track.streamUrl)
.setMediaMetadata(track.toMediaMetadata())
.build()
}
_state.value = AudioPlaybackState(
queue = playableQueue,
currentIndex = safeStartIndex,
isBuffering = true
)
player.setMediaItems(mediaItems, safeStartIndex, 0L)
player.prepare()
player.playWhenReady = true
startPlaybackService()
}
fun playTrack(track: TrackCard, queue: List<TrackCard>) {
val playableQueue = queue
.ifEmpty { listOf(track) }
.filter { it.streamUrl.isNotBlank() }
.ifEmpty { listOf(track).filter { it.streamUrl.isNotBlank() } }
val startIndex = playableQueue.indexOfFirst { it.id == track.id }
.takeIf { it >= 0 }
?: 0
playQueue(playableQueue, startIndex)
}
fun togglePlayPause() {
if (_state.value.currentTrack == null) return
if (player.isPlaying) {
player.pause()
} else {
player.play()
}
publishState()
}
fun next() {
if (player.hasNextMediaItem()) {
player.seekToNextMediaItem()
player.play()
}
publishState()
}
fun previous() {
if (player.currentPosition > RESTART_WINDOW_MS || !player.hasPreviousMediaItem()) {
player.seekTo(0L)
} else {
player.seekToPreviousMediaItem()
}
player.play()
publishState()
}
fun seekToProgress(progress: Float) {
val duration = player.duration.takeIf { it > 0L } ?: return
player.seekTo((duration * progress.coerceIn(0f, 1f)).toLong())
publishState()
}
fun release() {
scope.cancel()
player.release()
}
fun stop() {
player.stop()
_state.value = AudioPlaybackState()
}
private fun publishState() {
val existingErrorMessage = _state.value.errorMessage
val currentQueue = _state.value.queue
val currentIndex = player.currentMediaItemIndex.takeIf { it >= 0 } ?: _state.value.currentIndex
val duration = player.duration.takeIf { it > 0L } ?: currentQueue
.getOrNull(currentIndex)
?.durationSeconds
?.times(1_000L)
?: 0L
_state.value = _state.value.copy(
currentIndex = currentIndex,
isPlaying = player.isPlaying,
isBuffering = player.playbackState == Player.STATE_BUFFERING,
positionMs = player.currentPosition.coerceAtLeast(0L),
durationMs = duration,
errorMessage = existingErrorMessage
)
}
private companion object {
const val PROGRESS_TICK_MS = 500L
const val RESTART_WINDOW_MS = 3_000L
}
private fun startPlaybackService() {
val intent = Intent(context, FurumiPlaybackService::class.java)
ContextCompat.startForegroundService(context, intent)
}
}
private fun TrackCard.toMediaMetadata(): MediaMetadata {
val artist = artists.joinToString(", ")
.ifBlank { releaseTitle.orEmpty() }
.ifBlank { "Unknown artist" }
return MediaMetadata.Builder()
.setTitle(title)
.setArtist(artist)
.setAlbumTitle(releaseTitle)
.setArtworkUri(coverUrl?.let(Uri::parse))
.setMediaType(MediaMetadata.MEDIA_TYPE_MUSIC)
.build()
}
@@ -0,0 +1,378 @@
package com.example.furumi_android.ui.login
import android.net.Uri
import androidx.browser.customtabs.CustomTabsIntent
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.font.FontWeight
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.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.example.furumi_android.ui.theme.FurumiElectricCyan
import com.example.furumi_android.ui.theme.FurumiHotOrange
import com.example.furumi_android.ui.theme.FurumiInk
import com.example.furumi_android.ui.theme.FurumiLine
import com.example.furumi_android.ui.theme.FurumiNeonPink
import com.example.furumi_android.ui.theme.FurumiNeonViolet
import com.example.furumi_android.ui.theme.FurumiSurface
import com.example.furumi_android.ui.theme.FurumiSurfaceHigh
import com.example.furumi_android.ui.theme.FurumiTextMuted
@Composable
fun LoginScreen(
viewModel: LoginViewModel = hiltViewModel(),
onLoginSuccess: () -> Unit
) {
val uiState by viewModel.uiState.collectAsState()
val context = LocalContext.current
val focusManager = LocalFocusManager.current
val loginFocusRequester = remember { FocusRequester() }
val passwordFocusRequester = remember { FocusRequester() }
val isSsoLoading = uiState.loadingAction == LoginLoadingAction.Sso
val isPasswordLoading = uiState.loadingAction == LoginLoadingAction.Password
var isPasswordLoginVisible by rememberSaveable { mutableStateOf(false) }
LaunchedEffect(uiState.isAuthenticated) {
if (uiState.isAuthenticated) {
onLoginSuccess()
}
}
LaunchedEffect(uiState.externalAuthUrl) {
uiState.externalAuthUrl?.let { authUrl ->
CustomTabsIntent.Builder()
.build()
.launchUrl(context, Uri.parse(authUrl))
viewModel.onExternalAuthUrlOpened()
}
}
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.windowInsetsPadding(WindowInsets.safeDrawing)
.imePadding()
) {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp, vertical = 28.dp),
verticalArrangement = Arrangement.SpaceBetween
) {
Column {
BrandHeader()
Spacer(modifier = Modifier.height(34.dp))
AlbumTileStrip()
}
Spacer(modifier = Modifier.height(32.dp))
LoginControls(
uiState = uiState,
isPasswordLoginVisible = isPasswordLoginVisible,
isSsoLoading = isSsoLoading,
isPasswordLoading = isPasswordLoading,
loginFocusRequester = loginFocusRequester,
passwordFocusRequester = passwordFocusRequester,
onPasswordLoginVisibilityChange = {
isPasswordLoginVisible = !isPasswordLoginVisible
if (!isPasswordLoginVisible) {
focusManager.clearFocus()
}
},
onServerUrlChange = viewModel::onServerUrlChange,
onLoginChange = viewModel::onLoginChange,
onPasswordChange = viewModel::onPasswordChange,
onSsoClick = {
focusManager.clearFocus()
viewModel.loginWithSSO()
},
onPasswordLoginClick = {
focusManager.clearFocus()
viewModel.login()
}
)
}
}
}
@Composable
private fun BrandHeader() {
Row(
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.size(34.dp)
.clip(CircleShape)
.background(FurumiNeonPink),
contentAlignment = Alignment.Center
) {
Text(
text = "F",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onPrimary,
fontWeight = FontWeight.ExtraBold
)
}
Spacer(modifier = Modifier.width(10.dp))
Text(
text = "Furumi",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onBackground
)
}
}
@Composable
private fun AlbumTileStrip() {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
AlbumTile(
modifier = Modifier.weight(1f),
brush = Brush.linearGradient(listOf(FurumiNeonPink, FurumiSurfaceHigh))
)
AlbumTile(
modifier = Modifier.weight(1f),
brush = Brush.linearGradient(listOf(FurumiElectricCyan, FurumiInk))
)
AlbumTile(
modifier = Modifier.weight(1f),
brush = Brush.linearGradient(listOf(FurumiNeonViolet, FurumiHotOrange, FurumiSurface))
)
}
}
@Composable
private fun AlbumTile(
modifier: Modifier,
brush: Brush
) {
Box(
modifier = modifier
.aspectRatio(1f)
.clip(RoundedCornerShape(8.dp))
.background(brush)
.border(1.dp, Color.White.copy(alpha = 0.08f), RoundedCornerShape(8.dp))
)
}
@Composable
private fun LoginControls(
uiState: LoginUiState,
isPasswordLoginVisible: Boolean,
isSsoLoading: Boolean,
isPasswordLoading: Boolean,
loginFocusRequester: FocusRequester,
passwordFocusRequester: FocusRequester,
onPasswordLoginVisibilityChange: () -> Unit,
onServerUrlChange: (String) -> Unit,
onLoginChange: (String) -> Unit,
onPasswordChange: (String) -> Unit,
onSsoClick: () -> Unit,
onPasswordLoginClick: () -> Unit
) {
Column {
LoginTextField(
value = uiState.serverUrl,
onValueChange = onServerUrlChange,
label = "Server URL",
placeholder = "https://media.example.com",
enabled = !uiState.isLoading,
keyboardType = KeyboardType.Uri,
imeAction = if (isPasswordLoginVisible) ImeAction.Next else ImeAction.Go,
keyboardActions = KeyboardActions(
onGo = { onSsoClick() },
onNext = { loginFocusRequester.requestFocus() }
)
)
if (uiState.error != null) {
Spacer(modifier = Modifier.height(12.dp))
Text(
text = uiState.error,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium
)
}
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = onSsoClick,
modifier = Modifier
.fillMaxWidth()
.height(56.dp),
enabled = !uiState.isLoading,
shape = CircleShape,
colors = ButtonDefaults.buttonColors(
containerColor = FurumiNeonPink,
contentColor = MaterialTheme.colorScheme.onPrimary,
disabledContainerColor = FurumiSurfaceHigh,
disabledContentColor = FurumiTextMuted
)
) {
if (isSsoLoading) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
color = MaterialTheme.colorScheme.onPrimary,
strokeWidth = 2.dp
)
} else {
Text("Continue with SSO")
}
}
Spacer(modifier = Modifier.height(14.dp))
TextButton(
onClick = onPasswordLoginVisibilityChange,
modifier = Modifier.align(Alignment.CenterHorizontally),
enabled = !uiState.isLoading,
colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.onBackground)
) {
Text(
text = if (isPasswordLoginVisible) "Hide password login" else "Sign in with password",
style = MaterialTheme.typography.labelSmall
)
}
if (isPasswordLoginVisible) {
Spacer(modifier = Modifier.height(8.dp))
HorizontalDivider(color = FurumiLine)
Spacer(modifier = Modifier.height(18.dp))
LoginTextField(
value = uiState.login,
onValueChange = onLoginChange,
label = "Username or email",
placeholder = "name@example.com",
enabled = !uiState.isLoading,
keyboardType = KeyboardType.Email,
imeAction = ImeAction.Next,
keyboardActions = KeyboardActions(
onNext = { passwordFocusRequester.requestFocus() }
),
modifier = Modifier.focusRequester(loginFocusRequester)
)
Spacer(modifier = Modifier.height(10.dp))
LoginTextField(
value = uiState.password,
onValueChange = onPasswordChange,
label = "Password",
enabled = !uiState.isLoading,
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Done,
keyboardActions = KeyboardActions(
onDone = { onPasswordLoginClick() }
),
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.focusRequester(passwordFocusRequester)
)
Spacer(modifier = Modifier.height(16.dp))
OutlinedButton(
onClick = onPasswordLoginClick,
modifier = Modifier
.fillMaxWidth()
.height(54.dp),
enabled = !uiState.isLoading,
shape = CircleShape,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.onBackground),
colors = ButtonDefaults.outlinedButtonColors(
contentColor = MaterialTheme.colorScheme.onBackground,
disabledContentColor = FurumiTextMuted
)
) {
if (isPasswordLoading) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
color = MaterialTheme.colorScheme.onBackground,
strokeWidth = 2.dp
)
} else {
Text("Sign in")
}
}
}
}
}
@Composable
private fun LoginTextField(
value: String,
onValueChange: (String) -> Unit,
label: String,
enabled: Boolean,
keyboardType: KeyboardType,
imeAction: ImeAction,
keyboardActions: KeyboardActions,
modifier: Modifier = Modifier,
placeholder: String? = null,
visualTransformation: PasswordVisualTransformation? = null
) {
OutlinedTextField(
value = value,
onValueChange = onValueChange,
label = { Text(label) },
modifier = modifier.fillMaxWidth(),
placeholder = placeholder?.let { { Text(it) } },
enabled = enabled,
singleLine = true,
maxLines = 1,
visualTransformation = visualTransformation ?: androidx.compose.ui.text.input.VisualTransformation.None,
keyboardOptions = KeyboardOptions(
keyboardType = keyboardType,
imeAction = imeAction
),
keyboardActions = keyboardActions,
shape = RoundedCornerShape(8.dp),
colors = OutlinedTextFieldDefaults.colors(
focusedTextColor = MaterialTheme.colorScheme.onBackground,
unfocusedTextColor = MaterialTheme.colorScheme.onBackground,
disabledTextColor = FurumiTextMuted,
focusedContainerColor = FurumiSurface,
unfocusedContainerColor = FurumiSurface,
disabledContainerColor = FurumiSurface,
focusedBorderColor = FurumiNeonPink,
unfocusedBorderColor = FurumiLine,
disabledBorderColor = FurumiLine,
focusedLabelColor = FurumiNeonPink,
unfocusedLabelColor = FurumiTextMuted,
disabledLabelColor = FurumiTextMuted,
focusedPlaceholderColor = FurumiTextMuted,
unfocusedPlaceholderColor = FurumiTextMuted
)
)
}
@@ -0,0 +1,161 @@
package com.example.furumi_android.ui.login
import android.net.Uri
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.example.furumi_android.domain.model.ServerConfig
import com.example.furumi_android.domain.repository.AuthRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
data class LoginUiState(
val serverUrl: String = "",
val login: String = "",
val password: String = "",
val isLoading: Boolean = false,
val loadingAction: LoginLoadingAction? = null,
val error: String? = null,
val isAuthenticated: Boolean = false,
val externalAuthUrl: String? = null
)
enum class LoginLoadingAction {
Password,
Sso
}
@HiltViewModel
class LoginViewModel @Inject constructor(
private val authRepository: AuthRepository
) : ViewModel() {
private val _uiState = MutableStateFlow(
LoginUiState(
serverUrl = authRepository.getSavedServerUrl().orEmpty(),
isAuthenticated = authRepository.getCurrentSession() != null
)
)
val uiState: StateFlow<LoginUiState> = _uiState.asStateFlow()
fun onServerUrlChange(url: String) {
_uiState.value = _uiState.value.copy(serverUrl = url.withoutLineBreaks(), error = null)
}
fun onLoginChange(login: String) {
_uiState.value = _uiState.value.copy(login = login.withoutLineBreaks(), error = null)
}
fun onPasswordChange(password: String) {
_uiState.value = _uiState.value.copy(password = password.withoutLineBreaks(), error = null)
}
fun login() {
val state = _uiState.value
val password = state.password
if (password.isBlank()) {
_uiState.value = _uiState.value.copy(error = "Password is required")
return
}
val config = ServerConfig.credentials(state.serverUrl, state.login).getOrElse {
_uiState.value = _uiState.value.copy(error = it.message ?: "Check your login details")
return
}
viewModelScope.launch {
_uiState.value = _uiState.value.copy(
isLoading = true,
loadingAction = LoginLoadingAction.Password,
error = null
)
val result = authRepository.login(
config = config,
password = password
)
result.onSuccess {
_uiState.value = _uiState.value.copy(
isLoading = false,
loadingAction = null,
password = "",
serverUrl = it.serverBaseUrl,
isAuthenticated = true
)
}.onFailure {
_uiState.value = _uiState.value.copy(
isLoading = false,
loadingAction = null,
error = it.message
)
}
}
}
fun loginWithSSO() {
val state = _uiState.value
viewModelScope.launch {
_uiState.value = _uiState.value.copy(
isLoading = true,
loadingAction = LoginLoadingAction.Sso,
error = null
)
authRepository.createSsoAuthorizationUrl(state.serverUrl)
.onSuccess { authUrl ->
_uiState.value = _uiState.value.copy(
isLoading = false,
loadingAction = null,
externalAuthUrl = authUrl
)
}
.onFailure {
_uiState.value = _uiState.value.copy(
isLoading = false,
loadingAction = null,
error = it.message ?: "Unable to start SSO login"
)
}
}
}
fun onExternalAuthUrlOpened() {
_uiState.value = _uiState.value.copy(externalAuthUrl = null)
}
fun handleDeepLink(uri: Uri?) {
val callbackUrl = uri?.toString() ?: return
viewModelScope.launch {
_uiState.value = _uiState.value.copy(
isLoading = true,
loadingAction = LoginLoadingAction.Sso,
error = null
)
val result = authRepository.completeSsoLogin(callbackUrl)
result.onSuccess {
_uiState.value = _uiState.value.copy(
isLoading = false,
loadingAction = null,
serverUrl = it.serverBaseUrl,
isAuthenticated = true
)
}.onFailure {
_uiState.value = _uiState.value.copy(
isLoading = false,
loadingAction = null,
error = it.message
)
}
}
}
private fun String.withoutLineBreaks(): String {
return replace("\r", "").replace("\n", "")
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,411 @@
package com.example.furumi_android.ui.player
import android.graphics.Bitmap
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.example.furumi_android.data.repository.MediaImageLoader
import com.example.furumi_android.domain.model.ArtistCard
import com.example.furumi_android.domain.model.ArtistDetail
import com.example.furumi_android.domain.model.ListeningHistoryItem
import com.example.furumi_android.domain.model.ReleaseCard
import com.example.furumi_android.domain.model.ReleaseDetail
import com.example.furumi_android.domain.model.TrackCard
import com.example.furumi_android.domain.repository.AuthRepository
import com.example.furumi_android.domain.repository.PlayerRepository
import com.example.furumi_android.playback.AudioPlaybackState
import com.example.furumi_android.playback.PlaybackController
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
data class PlayerUiState(
val userName: String = "Listener",
val serverUrl: String = "",
val isLoggingOut: Boolean = false,
val isLoggedOut: Boolean = false,
val isListeningHistoryVisible: Boolean = false,
val isListeningHistoryLoading: Boolean = false,
val listeningHistory: List<ListeningHistoryItem> = emptyList(),
val listeningHistoryTotal: Long = 0,
val listeningHistoryError: String? = null,
val globalArtists: List<ArtistCard> = emptyList(),
val globalArtistsTotal: Long = 0,
val globalArtistsPage: Int = 0,
val globalArtistsHasMore: Boolean = true,
val isGlobalArtistsLoading: Boolean = false,
val isGlobalArtistsLoadingMore: Boolean = false,
val globalArtistsError: String? = null,
val selectedArtist: ArtistCard? = null,
val artistDetail: ArtistDetail? = null,
val isArtistDetailLoading: Boolean = false,
val artistDetailError: String? = null,
val selectedRelease: ReleaseCard? = null,
val releaseDetail: ReleaseDetail? = null,
val isReleaseDetailLoading: Boolean = false,
val releaseDetailError: String? = null,
val mediaImages: Map<String, Bitmap> = emptyMap(),
val loadingMediaImageUrls: Set<String> = emptySet(),
val playback: AudioPlaybackState = AudioPlaybackState()
)
@HiltViewModel
class PlayerViewModel @Inject constructor(
private val authRepository: AuthRepository,
private val playerRepository: PlayerRepository,
private val mediaImageLoader: MediaImageLoader,
private val playbackController: PlaybackController
) : ViewModel() {
private val artistDetailCache = mutableMapOf<Long, ArtistDetail>()
private val releaseDetailCache = mutableMapOf<Long, ReleaseDetail>()
private val _uiState = MutableStateFlow(
authRepository.getCurrentSession()?.let { session ->
PlayerUiState(
userName = session.user.name,
serverUrl = session.serverBaseUrl
)
} ?: PlayerUiState(
serverUrl = authRepository.getSavedServerUrl().orEmpty()
)
)
val uiState: StateFlow<PlayerUiState> = _uiState.asStateFlow()
init {
loadGlobalArtistsIfNeeded()
observePlayback()
}
fun loadGlobalArtistsIfNeeded() {
val state = _uiState.value
if (state.globalArtists.isNotEmpty() ||
state.isGlobalArtistsLoading ||
state.isGlobalArtistsLoadingMore
) {
return
}
loadGlobalArtists(page = 1, append = false)
}
fun retryGlobalArtists() {
loadGlobalArtists(page = 1, append = false)
}
fun loadMoreGlobalArtists() {
val state = _uiState.value
if (!state.globalArtistsHasMore ||
state.isGlobalArtistsLoading ||
state.isGlobalArtistsLoadingMore
) {
return
}
loadGlobalArtists(page = state.globalArtistsPage + 1, append = true)
}
fun openArtist(artist: ArtistCard) {
val cachedDetail = artistDetailCache[artist.id]
_uiState.value = _uiState.value.copy(
selectedArtist = cachedDetail?.artist ?: artist,
artistDetail = cachedDetail,
isArtistDetailLoading = cachedDetail == null,
artistDetailError = null
)
if (cachedDetail == null) {
loadArtistDetail(artist.id)
}
}
fun closeArtistDetail() {
_uiState.value = _uiState.value.copy(
selectedArtist = null,
artistDetail = null,
isArtistDetailLoading = false,
artistDetailError = null,
selectedRelease = null,
releaseDetail = null,
isReleaseDetailLoading = false,
releaseDetailError = null
)
}
fun retryArtistDetail() {
val artist = _uiState.value.selectedArtist ?: return
loadArtistDetail(artist.id)
}
fun openRelease(release: ReleaseCard) {
val cachedDetail = releaseDetailCache[release.id]
_uiState.value = _uiState.value.copy(
selectedRelease = cachedDetail?.release ?: release,
releaseDetail = cachedDetail,
isReleaseDetailLoading = cachedDetail == null,
releaseDetailError = null
)
if (cachedDetail == null) {
loadReleaseDetail(release.id)
}
}
fun closeReleaseDetail() {
_uiState.value = _uiState.value.copy(
selectedRelease = null,
releaseDetail = null,
isReleaseDetailLoading = false,
releaseDetailError = null
)
}
fun retryReleaseDetail() {
val release = _uiState.value.selectedRelease ?: return
loadReleaseDetail(release.id)
}
fun playTrack(track: TrackCard, queue: List<TrackCard>): Boolean {
if (track.streamUrl.isBlank()) return false
playbackController.playTrack(track, queue)
return true
}
fun playArtistTracks(shuffle: Boolean = false): Boolean {
val detail = _uiState.value.artistDetail ?: return false
val tracks = detail.topTracks
.ifEmpty { detail.featuredTracks }
.filter { it.streamUrl.isNotBlank() }
.let { if (shuffle) it.shuffled() else it }
if (tracks.isEmpty()) return false
playbackController.playQueue(tracks)
return true
}
fun playReleaseTracks(shuffle: Boolean = false): Boolean {
val tracks = _uiState.value.releaseDetail
?.tracks
.orEmpty()
.filter { it.streamUrl.isNotBlank() }
.let { if (shuffle) it.shuffled() else it }
if (tracks.isEmpty()) return false
playbackController.playQueue(tracks)
return true
}
fun togglePlayPause() {
playbackController.togglePlayPause()
}
fun nextTrack() {
playbackController.next()
}
fun previousTrack() {
playbackController.previous()
}
fun seekToPlaybackProgress(progress: Float) {
playbackController.seekToProgress(progress)
}
fun loadMediaImage(url: String?) {
val imageUrl = url?.takeIf { it.isNotBlank() } ?: return
val state = _uiState.value
if (state.mediaImages.containsKey(imageUrl) ||
state.loadingMediaImageUrls.contains(imageUrl)
) {
return
}
_uiState.value = state.copy(
loadingMediaImageUrls = state.loadingMediaImageUrls + imageUrl
)
viewModelScope.launch {
mediaImageLoader.load(imageUrl)
.onSuccess { bitmap ->
val currentState = _uiState.value
_uiState.value = currentState.copy(
mediaImages = currentState.mediaImages + (imageUrl to bitmap)
)
}
val currentState = _uiState.value
_uiState.value = currentState.copy(
loadingMediaImageUrls = currentState.loadingMediaImageUrls - imageUrl
)
}
}
fun openListeningHistory() {
_uiState.value = _uiState.value.copy(isListeningHistoryVisible = true)
if (_uiState.value.listeningHistory.isEmpty() && !_uiState.value.isListeningHistoryLoading) {
loadListeningHistory()
}
}
fun closeListeningHistory() {
_uiState.value = _uiState.value.copy(isListeningHistoryVisible = false)
}
fun retryListeningHistory() {
loadListeningHistory()
}
fun logout() {
if (_uiState.value.isLoggingOut) return
viewModelScope.launch {
_uiState.value = _uiState.value.copy(isLoggingOut = true)
authRepository.logout()
_uiState.value = _uiState.value.copy(
isLoggingOut = false,
isLoggedOut = true
)
}
}
private fun loadListeningHistory() {
viewModelScope.launch {
_uiState.value = _uiState.value.copy(
isListeningHistoryLoading = true,
listeningHistoryError = null
)
playerRepository.getListeningHistory(page = 1, limit = 20)
.onSuccess { page ->
_uiState.value = _uiState.value.copy(
isListeningHistoryLoading = false,
listeningHistory = page.items,
listeningHistoryTotal = page.total,
listeningHistoryError = null
)
}
.onFailure { error ->
_uiState.value = _uiState.value.copy(
isListeningHistoryLoading = false,
listeningHistoryError = error.message ?: "Unable to load listening history"
)
}
}
}
private fun observePlayback() {
viewModelScope.launch {
playbackController.state.collect { playbackState ->
_uiState.value = _uiState.value.copy(playback = playbackState)
}
}
}
private fun loadGlobalArtists(page: Int, append: Boolean) {
viewModelScope.launch {
val state = _uiState.value
_uiState.value = if (append) {
state.copy(
isGlobalArtistsLoadingMore = true,
globalArtistsError = null
)
} else {
state.copy(
isGlobalArtistsLoading = true,
isGlobalArtistsLoadingMore = false,
globalArtistsError = null,
globalArtistsHasMore = true
)
}
playerRepository.getArtists(page = page, limit = ARTIST_PAGE_SIZE, mine = false)
.onSuccess { artistPage ->
val currentArtists = if (append) _uiState.value.globalArtists else emptyList()
val mergedArtists = (currentArtists + artistPage.items).distinctBy { it.id }
_uiState.value = _uiState.value.copy(
globalArtists = mergedArtists,
globalArtistsTotal = artistPage.total,
globalArtistsPage = artistPage.page,
globalArtistsHasMore = artistPage.hasMore,
isGlobalArtistsLoading = false,
isGlobalArtistsLoadingMore = false,
globalArtistsError = null
)
}
.onFailure { error ->
_uiState.value = _uiState.value.copy(
isGlobalArtistsLoading = false,
isGlobalArtistsLoadingMore = false,
globalArtistsError = error.message ?: "Unable to load artists"
)
}
}
}
private fun loadArtistDetail(artistId: Long) {
viewModelScope.launch {
_uiState.value = _uiState.value.copy(
isArtistDetailLoading = true,
artistDetailError = null
)
playerRepository.getArtistDetail(artistId)
.onSuccess { detail ->
artistDetailCache[artistId] = detail
_uiState.value = _uiState.value.copy(
selectedArtist = detail.artist,
artistDetail = detail,
isArtistDetailLoading = false,
artistDetailError = null
)
}
.onFailure { error ->
_uiState.value = _uiState.value.copy(
isArtistDetailLoading = false,
artistDetailError = error.message ?: "Unable to load artist"
)
}
}
}
private fun loadReleaseDetail(releaseId: Long) {
viewModelScope.launch {
_uiState.value = _uiState.value.copy(
isReleaseDetailLoading = true,
releaseDetailError = null
)
playerRepository.getReleaseDetail(releaseId)
.onSuccess { detail ->
releaseDetailCache[releaseId] = detail
_uiState.value = _uiState.value.copy(
selectedRelease = detail.release,
releaseDetail = detail,
isReleaseDetailLoading = false,
releaseDetailError = null
)
}
.onFailure { error ->
_uiState.value = _uiState.value.copy(
isReleaseDetailLoading = false,
releaseDetailError = error.message ?: "Unable to load release"
)
}
}
}
private companion object {
const val ARTIST_PAGE_SIZE = 60
}
}
@@ -0,0 +1,51 @@
package com.example.furumi_android.ui.profile
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
@Composable
fun ProfileScreen(
viewModel: ProfileViewModel = hiltViewModel(),
onLoggedOut: () -> Unit
) {
val uiState by viewModel.uiState.collectAsState()
LaunchedEffect(uiState.isLoggedOut) {
if (uiState.isLoggedOut) {
onLoggedOut()
}
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(text = "Profile", style = MaterialTheme.typography.headlineMedium)
Spacer(modifier = Modifier.height(32.dp))
uiState.user?.let { user ->
Text(text = "Name: ${user.name}", style = MaterialTheme.typography.bodyLarge)
Text(text = "ID: ${user.id}", style = MaterialTheme.typography.bodyMedium)
Text(text = "Role: ${user.role}", style = MaterialTheme.typography.bodyMedium)
} ?: Text(text = "Loading user info...")
Spacer(modifier = Modifier.height(48.dp))
Button(
onClick = { viewModel.logout() },
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error)
) {
Text("Logout")
}
}
}
@@ -0,0 +1,37 @@
package com.example.furumi_android.ui.profile
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.example.furumi_android.domain.model.User
import com.example.furumi_android.domain.repository.AuthRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
data class ProfileUiState(
val user: User? = null,
val isLoggedOut: Boolean = false
)
@HiltViewModel
class ProfileViewModel @Inject constructor(
private val authRepository: AuthRepository
) : ViewModel() {
private val _uiState = MutableStateFlow(ProfileUiState())
val uiState: StateFlow<ProfileUiState> = _uiState.asStateFlow()
init {
_uiState.value = _uiState.value.copy(user = authRepository.getCurrentUser())
}
fun logout() {
viewModelScope.launch {
authRepository.logout()
_uiState.value = _uiState.value.copy(isLoggedOut = true)
}
}
}
@@ -0,0 +1,16 @@
package com.example.furumi_android.ui.theme
import androidx.compose.ui.graphics.Color
val FurumiBlack = Color(0xFF080A0E)
val FurumiInk = Color(0xFF111019)
val FurumiSurface = Color(0xFF1A1524)
val FurumiSurfaceHigh = Color(0xFF2A2036)
val FurumiLine = Color(0xFF4A385D)
val FurumiText = Color(0xFFFFF7FB)
val FurumiTextMuted = Color(0xFFC9B8D6)
val FurumiNeonPink = Color(0xFFFF3EA5)
val FurumiNeonPinkPressed = Color(0xFFE12A8E)
val FurumiElectricCyan = Color(0xFF37F3FF)
val FurumiNeonViolet = Color(0xFFA855F7)
val FurumiHotOrange = Color(0xFFFF7A3D)
@@ -0,0 +1,54 @@
package com.example.furumi_android.ui.theme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
private val DarkColorScheme = darkColorScheme(
primary = FurumiNeonPink,
onPrimary = FurumiBlack,
primaryContainer = FurumiNeonPinkPressed,
onPrimaryContainer = FurumiBlack,
secondary = FurumiElectricCyan,
onSecondary = FurumiBlack,
tertiary = FurumiNeonViolet,
background = FurumiBlack,
onBackground = FurumiText,
surface = FurumiInk,
onSurface = FurumiText,
surfaceVariant = FurumiSurface,
onSurfaceVariant = FurumiTextMuted,
outline = FurumiLine,
error = FurumiHotOrange,
onError = Color.White
)
private val LightColorScheme = lightColorScheme(
primary = Color(0xFFB90068),
onPrimary = Color.White,
secondary = Color(0xFF006B73),
tertiary = Color(0xFF6D27B8),
background = Color(0xFFFFF7FB),
onBackground = Color(0xFF17101F),
surface = Color.White,
onSurface = Color(0xFF17101F),
surfaceVariant = Color(0xFFF2E6F6),
onSurfaceVariant = Color(0xFF67536F),
outline = Color(0xFFD8BFE2)
)
@Composable
fun FurumiandroidTheme(
darkTheme: Boolean = true,
content: @Composable () -> Unit
) {
val colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
content = content
)
}
@@ -0,0 +1,59 @@
package com.example.furumi_android.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
val Typography = Typography(
headlineLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.ExtraBold,
fontSize = 34.sp,
lineHeight = 40.sp,
letterSpacing = 0.sp
),
headlineMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.ExtraBold,
fontSize = 28.sp,
lineHeight = 34.sp,
letterSpacing = 0.sp
),
titleMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.SemiBold,
fontSize = 18.sp,
lineHeight = 24.sp,
letterSpacing = 0.sp
),
bodyLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.sp
),
bodyMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.sp
),
labelLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Bold,
fontSize = 15.sp,
lineHeight = 20.sp,
letterSpacing = 0.sp
),
labelSmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.SemiBold,
fontSize = 12.sp,
lineHeight = 16.sp,
letterSpacing = 0.sp
)
)
+12
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 *;
#}
@@ -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>
@@ -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>
@@ -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>
@@ -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

+10
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>
+3
View File
@@ -0,0 +1,3 @@
<resources>
<string name="app_name">furumi-android</string>
</resources>
+5
View File
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.Furumiandroid" parent="android:Theme.Material.Light.NoActionBar" />
</resources>
+13
View File
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample backup rules file; uncomment and customize as necessary.
See https://developer.android.com/guide/topics/data/autobackup
for details.
Note: This file is ignored for devices older than API 31
See https://developer.android.com/about/versions/12/backup-restore
-->
<full-backup-content>
<!--
<include domain="sharedpref" path="."/>
<exclude domain="sharedpref" path="device.xml"/>
-->
</full-backup-content>
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample data extraction rules file; uncomment and customize as necessary.
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
for details.
-->
<data-extraction-rules>
<cloud-backup>
<!-- TODO: Use <include> and <exclude> to control what is backed up.
<include .../>
<exclude .../>
-->
</cloud-backup>
<!--
<device-transfer>
<include .../>
<exclude .../>
</device-transfer>
-->
</data-extraction-rules>
@@ -0,0 +1,17 @@
package com.example.furumi_android
import org.junit.Test
import org.junit.Assert.*
/**
* Example local unit test, which will execute on the development machine (host).
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
class ExampleUnitTest {
@Test
fun addition_isCorrect() {
assertEquals(4, 2 + 2)
}
}
@@ -0,0 +1,35 @@
package com.example.furumi_android.domain.model
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class ServerConfigTest {
@Test
fun normalizeBaseUrl_addsHttpsWhenSchemeIsMissing() {
val result = ServerConfig.normalizeBaseUrl("media.example.com")
assertEquals("https://media.example.com", result.getOrThrow())
}
@Test
fun normalizeBaseUrl_trimsTrailingSlashButKeepsPath() {
val result = ServerConfig.normalizeBaseUrl(" https://media.example.com/furumi/ ")
assertEquals("https://media.example.com/furumi", result.getOrThrow())
}
@Test
fun normalizeBaseUrl_rejectsQueryStrings() {
val result = ServerConfig.normalizeBaseUrl("https://media.example.com?token=bad")
assertTrue(result.isFailure)
}
@Test
fun credentials_requireLogin() {
val result = ServerConfig.credentials("https://media.example.com", " ")
assertTrue(result.isFailure)
}
}