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
+15
View File
@@ -0,0 +1,15 @@
*.iml
.gradle
/local.properties
/.idea/caches
/.idea/libraries
/.idea/modules.xml
/.idea/workspace.xml
/.idea/navEditor.xml
/.idea/assetWizardSettings.xml
.DS_Store
/build
/captures
.externalNativeBuild
.cxx
local.properties
+3
View File
@@ -0,0 +1,3 @@
# Default ignored files
/shelf/
/workspace.xml
Generated
+1
View File
@@ -0,0 +1 @@
furumi-android
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AndroidProjectSystem">
<option name="providerId" value="com.android.tools.idea.GradleProjectSystem" />
</component>
</project>
+123
View File
@@ -0,0 +1,123 @@
<component name="ProjectCodeStyleConfiguration">
<code_scheme name="Project" version="173">
<JetCodeStyleSettings>
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
</JetCodeStyleSettings>
<codeStyleSettings language="XML">
<option name="FORCE_REARRANGE_MODE" value="1" />
<indentOptions>
<option name="CONTINUATION_INDENT_SIZE" value="4" />
</indentOptions>
<arrangement>
<rules>
<section>
<rule>
<match>
<AND>
<NAME>xmlns:android</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>xmlns:.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:id</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:name</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>name</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>style</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
<order>ANDROID_ATTRIBUTE_ORDER</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>.*</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
</rules>
</arrangement>
</codeStyleSettings>
<codeStyleSettings language="kotlin">
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
</codeStyleSettings>
</code_scheme>
</component>
+5
View File
@@ -0,0 +1,5 @@
<component name="ProjectCodeStyleConfiguration">
<state>
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
</state>
</component>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<bytecodeTargetLevel target="21" />
</component>
</project>
+18
View File
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="deploymentTargetSelector">
<selectionStates>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2026-06-05T00:15:24.764079Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="PhysicalDevice" identifier="serial=R5CW6292TGT" />
</handle>
</Target>
</DropdownSelection>
<DialogSelection />
</SelectionState>
</selectionStates>
</component>
</project>
+13
View File
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DeviceTable">
<option name="columnSorters">
<list>
<ColumnSorterState>
<option name="column" value="Name" />
<option name="order" value="ASCENDING" />
</ColumnSorterState>
</list>
</option>
</component>
</project>
+18
View File
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GradleMigrationSettings" migrationVersion="1" />
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>
<option name="testRunner" value="CHOOSE_PER_TEST" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$" />
<option value="$PROJECT_DIR$/app" />
</set>
</option>
</GradleProjectSettings>
</option>
</component>
</project>
+61
View File
@@ -0,0 +1,61 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="ComposePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="ComposePreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="ComposePreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="ComposePreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewAnnotationInFunctionWithParameters" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewApiLevelMustBeValid" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewDeviceShouldUseNewSpec" enabled="true" level="WEAK WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewFontScaleMustBeGreaterThanZero" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewMultipleParameterProviders" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewParameterProviderOnFirstParameter" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewPickerAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
</profile>
</component>
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" />
</component>
<component name="ProjectType">
<option name="id" value="Android" />
</component>
</project>
+17
View File
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="RunConfigurationProducerService">
<option name="ignoredProducers">
<set>
<option value="com.intellij.execution.junit.AbstractAllInDirectoryConfigurationProducer" />
<option value="com.intellij.execution.junit.AllInPackageConfigurationProducer" />
<option value="com.intellij.execution.junit.PatternConfigurationProducer" />
<option value="com.intellij.execution.junit.TestInClassConfigurationProducer" />
<option value="com.intellij.execution.junit.UniqueIdConfigurationProducer" />
<option value="com.intellij.execution.junit.testDiscovery.JUnitTestDiscoveryConfigurationProducer" />
<option value="org.jetbrains.kotlin.idea.junit.KotlinJUnitRunConfigurationProducer" />
<option value="org.jetbrains.kotlin.idea.junit.KotlinPatternConfigurationProducer" />
</set>
</option>
</component>
</project>
Generated
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>
+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)
}
}
+7
View File
@@ -0,0 +1,7 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.compose) apply false
alias(libs.plugins.hilt) apply false
alias(libs.plugins.ksp) apply false
}
+20
View File
@@ -0,0 +1,20 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. For more details, visit
# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
# org.gradle.parallel=true
# When enabled, the Configuration Cache allows Gradle to skip the configuration
# phase entirely if nothing that affects the build configuration (such as build scripts)
# has changed. Additionally, Gradle applies performance optimizations to task execution.
org.gradle.configuration-cache=true
# Kotlin code style for this project: "official" or "obsolete":
kotlin.code.style=official
android.disallowKotlinSourceSets=false
+12
View File
@@ -0,0 +1,12 @@
#This file is generated by updateDaemonJvm
toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect
toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect
toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect
toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect
toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/73bcfb608d1fde9fb62e462f834a3299/redirect
toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/846ee0d876d26a26f37aa1ce8de73224/redirect
toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect
toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect
toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/9482ddec596298c84656d31d16652665/redirect
toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/39701d92e1756bb2f141eb67cd4c660e/redirect
toolchainVersion=21
+60
View File
@@ -0,0 +1,60 @@
[versions]
agp = "9.2.1"
coreKtx = "1.10.1"
junit = "4.13.2"
junitVersion = "1.1.5"
espressoCore = "3.5.1"
lifecycleRuntimeKtx = "2.6.1"
lifecycleViewmodelCompose = "2.6.1"
activityCompose = "1.8.0"
kotlin = "2.2.10"
composeBom = "2026.02.01"
retrofit = "2.9.0"
okhttp = "4.12.0"
moshi = "1.15.0"
securityCrypto = "1.1.0-alpha06"
datastore = "1.0.0"
browser = "1.8.0"
hilt = "2.59.2"
ksp = "2.2.10-2.0.2"
navigation = "2.8.8"
media3 = "1.8.0"
[libraries]
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigation" }
hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
hilt-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" }
hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version = "1.2.0" }
retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
retrofit-moshi = { group = "com.squareup.retrofit2", name = "converter-moshi", version.ref = "retrofit" }
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" }
moshi = { group = "com.squareup.moshi", name = "moshi-kotlin", version.ref = "moshi" }
androidx-security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "securityCrypto" }
androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
androidx-browser = { group = "androidx.browser", name = "browser", version.ref = "browser" }
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleViewmodelCompose" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-media3-exoplayer = { group = "androidx.media3", name = "media3-exoplayer", version.ref = "media3" }
androidx-media3-datasource-okhttp = { group = "androidx.media3", name = "media3-datasource-okhttp", version.ref = "media3" }
androidx-media3-session = { group = "androidx.media3", name = "media3-session", version.ref = "media3" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
Binary file not shown.
+8
View File
@@ -0,0 +1,8 @@
#Thu Jun 04 19:43:31 EEST 2026
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored Executable
+251
View File
@@ -0,0 +1,251 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
Vendored
+94
View File
@@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+26
View File
@@ -0,0 +1,26 @@
pluginManagement {
repositories {
google {
content {
includeGroupByRegex("com\\.android.*")
includeGroupByRegex("com\\.google.*")
includeGroupByRegex("androidx.*")
}
}
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "furumi-android"
include(":app")