From b2d8f3702cea48d9aa0e98c2f9e0f3a7746ab21d Mon Sep 17 00:00:00 2001 From: m Date: Fri, 11 Sep 2026 12:40:50 +0200 Subject: [PATCH] kotlin refacto mobile app --- mobile-kotlin/.gitignore | 6 + mobile-kotlin/app/build.gradle.kts | 84 ++++++ mobile-kotlin/app/proguard-rules.pro | 2 + .../app/src/main/AndroidManifest.xml | 26 ++ .../java/com/vaultdrop/mobile/MainActivity.kt | 23 ++ .../vaultdrop/mobile/VaultDropApplication.kt | 16 ++ .../vaultdrop/mobile/auth/SecureTokenStore.kt | 63 +++++ .../vaultdrop/mobile/auth/SessionManager.kt | 46 ++++ .../vaultdrop/mobile/auth/TokenProvider.kt | 16 ++ .../mobile/data/local/AppDatabase.kt | 23 ++ .../mobile/data/local/dao/FolderDao.kt | 43 +++ .../data/local/dao/UserPreferenceDao.kt | 27 ++ .../mobile/data/local/entity/FolderEntity.kt | 54 ++++ .../data/local/entity/UserPreferenceEntity.kt | 21 ++ .../mobile/data/local/migration/Migrations.kt | 31 +++ .../vaultdrop/mobile/data/remote/ApiClient.kt | 101 +++++++ .../mobile/data/remote/ApiService.kt | 34 +++ .../mobile/data/remote/AuthInterceptor.kt | 27 ++ .../vaultdrop/mobile/data/remote/dto/Dtos.kt | 71 +++++ .../mobile/data/repository/AuthRepository.kt | 36 +++ .../data/repository/FolderRepository.kt | 91 +++++++ .../java/com/vaultdrop/mobile/di/AppModule.kt | 12 + .../com/vaultdrop/mobile/di/DatabaseModule.kt | 32 +++ .../com/vaultdrop/mobile/di/NetworkModule.kt | 60 +++++ .../mobile/domain/ActiveUserStore.kt | 26 ++ .../vaultdrop/mobile/domain/DeviceIdentity.kt | 27 ++ .../com/vaultdrop/mobile/domain/GenerateId.kt | 21 ++ .../com/vaultdrop/mobile/domain/PrefKeys.kt | 8 + .../com/vaultdrop/mobile/ui/auth/AuthState.kt | 16 ++ .../vaultdrop/mobile/ui/auth/AuthViewModel.kt | 86 ++++++ .../vaultdrop/mobile/ui/auth/LoginScreen.kt | 135 ++++++++++ .../mobile/ui/folderlist/FolderListScreen.kt | 141 ++++++++++ .../mobile/ui/folderlist/FolderListUiState.kt | 9 + .../ui/folderlist/FolderListViewModel.kt | 58 ++++ .../mobile/ui/navigation/NavGraph.kt | 24 ++ .../mobile/ui/navigation/VaultDropApp.kt | 44 +++ .../com/vaultdrop/mobile/ui/theme/Color.kt | 9 + .../com/vaultdrop/mobile/ui/theme/Theme.kt | 34 +++ .../com/vaultdrop/mobile/ui/theme/Type.kt | 24 ++ .../res/drawable/ic_launcher_background.xml | 10 + .../res/drawable/ic_launcher_foreground.xml | 13 + .../res/mipmap-anydpi-v26/ic_launcher.xml | 5 + .../app/src/main/res/values-en/strings.xml | 25 ++ .../app/src/main/res/values/strings.xml | 26 ++ .../app/src/main/res/values/themes.xml | 3 + mobile-kotlin/build.gradle.kts | 7 + mobile-kotlin/gradle.properties | 6 + mobile-kotlin/gradle/libs.versions.toml | 53 ++++ .../gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43583 bytes .../gradle/wrapper/gradle-wrapper.properties | 7 + mobile-kotlin/gradlew | 252 ++++++++++++++++++ mobile-kotlin/gradlew.bat | 94 +++++++ mobile-kotlin/settings.gradle.kts | 24 ++ 53 files changed, 2132 insertions(+) create mode 100644 mobile-kotlin/.gitignore create mode 100644 mobile-kotlin/app/build.gradle.kts create mode 100644 mobile-kotlin/app/proguard-rules.pro create mode 100644 mobile-kotlin/app/src/main/AndroidManifest.xml create mode 100644 mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/MainActivity.kt create mode 100644 mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/VaultDropApplication.kt create mode 100644 mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/auth/SecureTokenStore.kt create mode 100644 mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/auth/SessionManager.kt create mode 100644 mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/auth/TokenProvider.kt create mode 100644 mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/AppDatabase.kt create mode 100644 mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/dao/FolderDao.kt create mode 100644 mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/dao/UserPreferenceDao.kt create mode 100644 mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/entity/FolderEntity.kt create mode 100644 mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/entity/UserPreferenceEntity.kt create mode 100644 mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/migration/Migrations.kt create mode 100644 mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/remote/ApiClient.kt create mode 100644 mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/remote/ApiService.kt create mode 100644 mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/remote/AuthInterceptor.kt create mode 100644 mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/remote/dto/Dtos.kt create mode 100644 mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/repository/AuthRepository.kt create mode 100644 mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/repository/FolderRepository.kt create mode 100644 mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/di/AppModule.kt create mode 100644 mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/di/DatabaseModule.kt create mode 100644 mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/di/NetworkModule.kt create mode 100644 mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/domain/ActiveUserStore.kt create mode 100644 mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/domain/DeviceIdentity.kt create mode 100644 mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/domain/GenerateId.kt create mode 100644 mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/domain/PrefKeys.kt create mode 100644 mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/auth/AuthState.kt create mode 100644 mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/auth/AuthViewModel.kt create mode 100644 mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/auth/LoginScreen.kt create mode 100644 mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderlist/FolderListScreen.kt create mode 100644 mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderlist/FolderListUiState.kt create mode 100644 mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderlist/FolderListViewModel.kt create mode 100644 mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/navigation/NavGraph.kt create mode 100644 mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/navigation/VaultDropApp.kt create mode 100644 mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/theme/Color.kt create mode 100644 mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/theme/Theme.kt create mode 100644 mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/theme/Type.kt create mode 100644 mobile-kotlin/app/src/main/res/drawable/ic_launcher_background.xml create mode 100644 mobile-kotlin/app/src/main/res/drawable/ic_launcher_foreground.xml create mode 100644 mobile-kotlin/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 mobile-kotlin/app/src/main/res/values-en/strings.xml create mode 100644 mobile-kotlin/app/src/main/res/values/strings.xml create mode 100644 mobile-kotlin/app/src/main/res/values/themes.xml create mode 100644 mobile-kotlin/build.gradle.kts create mode 100644 mobile-kotlin/gradle.properties create mode 100644 mobile-kotlin/gradle/libs.versions.toml create mode 100644 mobile-kotlin/gradle/wrapper/gradle-wrapper.jar create mode 100644 mobile-kotlin/gradle/wrapper/gradle-wrapper.properties create mode 100755 mobile-kotlin/gradlew create mode 100644 mobile-kotlin/gradlew.bat create mode 100644 mobile-kotlin/settings.gradle.kts diff --git a/mobile-kotlin/.gitignore b/mobile-kotlin/.gitignore new file mode 100644 index 0000000..fdc3bc9 --- /dev/null +++ b/mobile-kotlin/.gitignore @@ -0,0 +1,6 @@ +.gradle/ +build/ +local.properties +*.iml +.idea/ +.kotlin/ \ No newline at end of file diff --git a/mobile-kotlin/app/build.gradle.kts b/mobile-kotlin/app/build.gradle.kts new file mode 100644 index 0000000..e9135ad --- /dev/null +++ b/mobile-kotlin/app/build.gradle.kts @@ -0,0 +1,84 @@ +import java.util.Properties + +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.ksp) + alias(libs.plugins.hilt) +} + +val apiBaseUrl: String = providers.gradleProperty("VAULTDROP_API_BASE_URL") + .getOrElse("http://10.0.2.2:8080/api/v1") + +android { + namespace = "com.vaultdrop.mobile" + compileSdk = 35 + + defaultConfig { + applicationId = "com.vaultdrop.mobile" + minSdk = 26 + targetSdk = 35 + versionCode = 1 + versionName = "0.1.0" + + buildConfigField("String", "API_BASE_URL", "\"$apiBaseUrl\"") + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro", + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + kotlinOptions { + jvmTarget = "17" + } + buildFeatures { + compose = true + buildConfig = true + } +} + +dependencies { + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.lifecycle.runtime.compose) + implementation(libs.androidx.lifecycle.viewmodel.compose) + implementation(libs.androidx.activity.compose) + + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.ui.graphics) + implementation(libs.androidx.compose.ui.tooling.preview) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.material.icons.extended) + implementation(libs.androidx.navigation.compose) + + implementation(libs.androidx.room.runtime) + implementation(libs.androidx.room.ktx) + ksp(libs.androidx.room.compiler) + + implementation(libs.hilt.android) + ksp(libs.hilt.compiler) + implementation(libs.androidx.hilt.navigation.compose) + + implementation(libs.retrofit) + implementation(libs.retrofit.converter.moshi) + implementation(libs.okhttp) + implementation(libs.okhttp.logging) + implementation(libs.moshi) + implementation(libs.moshi.kotlin) + + implementation(libs.androidx.security.crypto) + implementation(libs.timber) + + debugImplementation(libs.androidx.compose.ui.tooling) +} \ No newline at end of file diff --git a/mobile-kotlin/app/proguard-rules.pro b/mobile-kotlin/app/proguard-rules.pro new file mode 100644 index 0000000..1263f9e --- /dev/null +++ b/mobile-kotlin/app/proguard-rules.pro @@ -0,0 +1,2 @@ +# Project-specific ProGuard rules (R8). +# Rien pour la verticale minimale. \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/AndroidManifest.xml b/mobile-kotlin/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..bbde9ae --- /dev/null +++ b/mobile-kotlin/app/src/main/AndroidManifest.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/MainActivity.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/MainActivity.kt new file mode 100644 index 0000000..59f6fd3 --- /dev/null +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/MainActivity.kt @@ -0,0 +1,23 @@ +package com.vaultdrop.mobile + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import com.vaultdrop.mobile.ui.navigation.VaultDropApp +import com.vaultdrop.mobile.ui.theme.VaultDropTheme +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class MainActivity : ComponentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { + VaultDropTheme { + VaultDropApp() + } + } + } +} \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/VaultDropApplication.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/VaultDropApplication.kt new file mode 100644 index 0000000..9e55fff --- /dev/null +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/VaultDropApplication.kt @@ -0,0 +1,16 @@ +package com.vaultdrop.mobile + +import android.app.Application +import dagger.hilt.android.HiltAndroidApp +import timber.log.Timber + +@HiltAndroidApp +class VaultDropApplication : Application() { + + override fun onCreate() { + super.onCreate() + if (BuildConfig.DEBUG) { + Timber.plant(Timber.DebugTree()) + } + } +} \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/auth/SecureTokenStore.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/auth/SecureTokenStore.kt new file mode 100644 index 0000000..cf439b9 --- /dev/null +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/auth/SecureTokenStore.kt @@ -0,0 +1,63 @@ +package com.vaultdrop.mobile.auth + +import android.content.Context +import android.content.SharedPreferences +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKeys +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.Moshi +import com.vaultdrop.mobile.data.remote.dto.UserDto +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Persistance chiffrée du token paseto et du profil compte (Keystore + AES). + * Miroir de `services/secureStore.ts` (expo-secure-store). Le token n'est + * JAMAIS écrit en clair dans SQLite. + */ +@Singleton +class SecureTokenStore @Inject constructor( + @ApplicationContext context: Context, + moshi: Moshi, +) { + + private val userAdapter: JsonAdapter = moshi.adapter(UserDto::class.java) + + private val prefs: SharedPreferences = run { + val masterKeyAlias = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC) + EncryptedSharedPreferences.create( + FILE_NAME, + masterKeyAlias, + context, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, + ) + } + + fun getToken(): String? = prefs.getString(KEY_TOKEN, null) + + fun getUser(): UserDto? { + val raw = prefs.getString(KEY_ACCOUNT, null) ?: return null + return runCatching { userAdapter.fromJson(raw) } + .getOrNull() + ?.takeIf { it.id.isNotEmpty() && it.username.isNotEmpty() } + } + + fun save(token: String, user: UserDto) { + prefs.edit() + .putString(KEY_TOKEN, token) + .putString(KEY_ACCOUNT, userAdapter.toJson(user)) + .apply() + } + + fun clear() { + prefs.edit().remove(KEY_TOKEN).remove(KEY_ACCOUNT).apply() + } + + companion object { + private const val FILE_NAME = "vaultdrop_secure" + private const val KEY_TOKEN = "vaultdrop.auth_token" + private const val KEY_ACCOUNT = "vaultdrop.account" + } +} \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/auth/SessionManager.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/auth/SessionManager.kt new file mode 100644 index 0000000..572c92e --- /dev/null +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/auth/SessionManager.kt @@ -0,0 +1,46 @@ +package com.vaultdrop.mobile.auth + +import com.vaultdrop.mobile.data.remote.ApiClient +import com.vaultdrop.mobile.data.remote.dto.LoginResponseDto +import com.vaultdrop.mobile.domain.ActiveUserStore +import com.vaultdrop.mobile.domain.DeviceIdentity +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Orchestration de la session (restauration/sauvegarde/purge) : relie le store + * sécurisé, le miroir `active_user_id` et le token en mémoire lu par + * `AuthInterceptor`. + */ +@Singleton +class SessionManager @Inject constructor( + private val secureTokenStore: SecureTokenStore, + private val activeUserStore: ActiveUserStore, + private val tokenProvider: TokenProvider, +) { + + /** Restaure la session persistée (si présente), sinon null. */ + suspend fun restore(): AuthSession? { + val token = secureTokenStore.getToken() ?: return null + val user = secureTokenStore.getUser() ?: return null + tokenProvider.current = token + return AuthSession(token = token, user = user) + } + + suspend fun save(token: String, user: com.vaultdrop.mobile.data.remote.dto.UserDto) { + secureTokenStore.save(token, user) + activeUserStore.set(user.id) + tokenProvider.current = token + } + + suspend fun clear() { + secureTokenStore.clear() + activeUserStore.clear() + tokenProvider.current = null + } +} + +data class AuthSession( + val token: String, + val user: com.vaultdrop.mobile.data.remote.dto.UserDto, +) \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/auth/TokenProvider.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/auth/TokenProvider.kt new file mode 100644 index 0000000..5e5c9ac --- /dev/null +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/auth/TokenProvider.kt @@ -0,0 +1,16 @@ +package com.vaultdrop.mobile.auth + +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Fournisseur du token paseto en mémoire. Remplacé plus tard par une source + * persistante (EncryptedSharedPreferences / Keystore) une fois le login intégré. + * `null` = mode local. + */ +@Singleton +class TokenProvider @Inject constructor() { + + @Volatile + var current: String? = null +} \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/AppDatabase.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/AppDatabase.kt new file mode 100644 index 0000000..d77e6ba --- /dev/null +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/AppDatabase.kt @@ -0,0 +1,23 @@ +package com.vaultdrop.mobile.data.local + +import androidx.room.Database +import androidx.room.RoomDatabase +import com.vaultdrop.mobile.data.local.dao.FolderDao +import com.vaultdrop.mobile.data.local.dao.UserPreferenceDao +import com.vaultdrop.mobile.data.local.entity.FolderEntity +import com.vaultdrop.mobile.data.local.entity.UserPreferenceEntity + +/* + * DB SQLite locale, `dot.db` (même nom que la version Expo). + * v1: folders ; v2: user_preferences. + */ +@Database( + entities = [FolderEntity::class, UserPreferenceEntity::class], + version = 2, + exportSchema = false, +) +abstract class AppDatabase : RoomDatabase() { + + abstract fun folderDao(): FolderDao + abstract fun userPreferenceDao(): UserPreferenceDao +} \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/dao/FolderDao.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/dao/FolderDao.kt new file mode 100644 index 0000000..2833f57 --- /dev/null +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/dao/FolderDao.kt @@ -0,0 +1,43 @@ +package com.vaultdrop.mobile.data.local.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Upsert +import com.vaultdrop.mobile.data.local.entity.FolderEntity +import kotlinx.coroutines.flow.Flow + +@Dao +interface FolderDao { + + @Query("SELECT * FROM folders ORDER BY name ASC") + fun observeAll(): Flow> + + @Query("SELECT * FROM folders WHERE parent_resource_id IS NULL ORDER BY name ASC") + fun observeRootFolders(): Flow> + + @Query("SELECT * FROM folders WHERE parent_resource_id IS NULL ORDER BY name ASC") + suspend fun getRootFolders(): List + + @Query("SELECT * FROM folders WHERE parent_resource_id = :parentResourceId ORDER BY name ASC") + suspend fun getByParent(parentResourceId: String): List + + @Query("SELECT * FROM folders WHERE resource_id = :resourceId LIMIT 1") + suspend fun getByResourceId(resourceId: String): FolderEntity? + + @Query("SELECT * FROM folders WHERE uri = :uri LIMIT 1") + suspend fun getByUri(uri: String): FolderEntity? + + @Upsert + suspend fun upsert(folder: FolderEntity) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsertAll(folders: List) + + @Query("UPDATE folders SET \"exists\" = 0, updated_at = :updatedAt WHERE resource_id = :resourceId") + suspend fun markMissing(resourceId: String, updatedAt: Long) + + @Query("DELETE FROM folders WHERE resource_id = :resourceId") + suspend fun remove(resourceId: String) +} \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/dao/UserPreferenceDao.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/dao/UserPreferenceDao.kt new file mode 100644 index 0000000..f139197 --- /dev/null +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/dao/UserPreferenceDao.kt @@ -0,0 +1,27 @@ +package com.vaultdrop.mobile.data.local.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Upsert +import com.vaultdrop.mobile.data.local.entity.UserPreferenceEntity + +@Dao +interface UserPreferenceDao { + + @Query("SELECT * FROM user_preferences WHERE `key` = :key LIMIT 1") + suspend fun get(key: String): UserPreferenceEntity? + + @Query("SELECT value FROM user_preferences WHERE `key` = :key LIMIT 1") + suspend fun getValue(key: String): String? + + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insertIgnore(pref: UserPreferenceEntity) + + @Upsert + suspend fun upsert(pref: UserPreferenceEntity) + + @Query("DELETE FROM user_preferences WHERE `key` = :key") + suspend fun delete(key: String) +} \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/entity/FolderEntity.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/entity/FolderEntity.kt new file mode 100644 index 0000000..d6a840d --- /dev/null +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/entity/FolderEntity.kt @@ -0,0 +1,54 @@ +package com.vaultdrop.mobile.data.local.entity + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey + +/** + * Miroir de `FolderRow` (mobile/services/db/types.ts). + * + * `uri` est NULL pour les dossiers cloud-only ; l'index unique est posé sur la + * colonne directement (SQLite traite les NULL comme distincts, donc plusieurs + * lignes sans uri cohabitent — équivalent sémantique de l'index partiel + * `WHERE uri IS NOT NULL` du schéma JS). + * + * Statuts : 'local' | 'cloud' | 'local-cloud' (placement). + */ +@Entity( + tableName = "folders", + indices = [ + Index(value = ["resource_id"], unique = true), + Index(value = ["uri"], unique = true), + Index(value = ["parent_resource_id"]), + ], +) +data class FolderEntity( + @PrimaryKey(autoGenerate = true) + val id: Long = 0L, + @ColumnInfo(name = "resource_id") + val resourceId: String, + @ColumnInfo(name = "uri") + val uri: String? = null, + @ColumnInfo(name = "name") + val name: String, + @ColumnInfo(name = "exists") + val exists: Int? = null, + @ColumnInfo(name = "parent_resource_id") + val parentResourceId: String? = null, + @ColumnInfo(name = "owner_id") + val ownerId: String? = null, + @ColumnInfo(name = "sync_status") + val syncStatus: String = "local", + @ColumnInfo(name = "added_at") + val addedAt: Long, + @ColumnInfo(name = "updated_at") + val updatedAt: Long, +) + +/** Placement d'un dossier — miroir de `SyncStatus` JS. */ +object FolderStatus { + const val LOCAL = "local" + const val CLOUD = "cloud" + const val LOCAL_CLOUD = "local-cloud" +} \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/entity/UserPreferenceEntity.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/entity/UserPreferenceEntity.kt new file mode 100644 index 0000000..bb05b40 --- /dev/null +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/entity/UserPreferenceEntity.kt @@ -0,0 +1,21 @@ +package com.vaultdrop.mobile.data.local.entity + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey + +/** + * Miroir de la table `user_preferences` JS (`key`/`value`/`updated_at`). + * Clés connues : `device_user_id` (32-hex, généré une fois), `sync_mode`, + * `active_user_id` (miroir non-sensible du compte connecté). + */ +@Entity(tableName = "user_preferences") +data class UserPreferenceEntity( + @PrimaryKey + @ColumnInfo(name = "key") + val key: String, + @ColumnInfo(name = "value") + val value: String, + @ColumnInfo(name = "updated_at") + val updatedAt: Long, +) \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/migration/Migrations.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/migration/Migrations.kt new file mode 100644 index 0000000..5a7c8a2 --- /dev/null +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/local/migration/Migrations.kt @@ -0,0 +1,31 @@ +package com.vaultdrop.mobile.data.local.migration + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +/** + * Versions Room du schéma Kotlin. + * + * v1 : table `folders` (créée par Room à partir de l'entité). + * v2 : table `user_preferences` — clé/valeur pour l'identité device, le miroir + * du compte actif et les préférences utilisateur. + */ +object Migrations { + + private val MIGRATION_1_2 = object : Migration(1, 2) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS `user_preferences` ( + `key` TEXT NOT NULL, + `value` TEXT NOT NULL, + `updated_at` INTEGER NOT NULL, + PRIMARY KEY(`key`) + ) + """.trimIndent(), + ) + } + } + + val ALL: Array = arrayOf(MIGRATION_1_2) +} \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/remote/ApiClient.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/remote/ApiClient.kt new file mode 100644 index 0000000..ae0e43f --- /dev/null +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/remote/ApiClient.kt @@ -0,0 +1,101 @@ +package com.vaultdrop.mobile.data.remote + +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.Moshi +import com.vaultdrop.mobile.data.remote.dto.ApiEnvelope +import com.vaultdrop.mobile.data.remote.dto.ApiErrorEnvelope +import com.vaultdrop.mobile.data.remote.dto.DeviceRegistrationDto +import com.vaultdrop.mobile.data.remote.dto.FolderDto +import com.vaultdrop.mobile.data.remote.dto.LoginRequestDto +import com.vaultdrop.mobile.data.remote.dto.LoginResponseDto +import okio.IOException +import retrofit2.Response +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Erreur normalisée du client — équivalent de `ApiError` JS. + * `code` = identifiant contractuel (`NETWORK_ERROR`, `HTTP_`, + * `INVALID_RESPONSE`, ou code du body serveur). + */ +class ApiException( + val code: String, + override val message: String, + val httpCode: Int, +) : Exception(message) + +/** + * Déballage de l'enveloppe API. Miroir de `request()` dans `api/client.ts` : + * - échec transport → `NETWORK_ERROR` ; + * - non-2xx → `ApiException` (code du body si présent, sinon `HTTP_`), + * et un 401 sur un endpoint protégé déclenche `onUnauthorized` (sauf si + * `skipUnauthorizedHandling` — cas du login, où 401 = identifiants erronés) ; + * - 2xx sans `data` (ou corps illisible) → `INVALID_RESPONSE`. + */ +@Singleton +class ApiClient @Inject constructor( + private val apiService: ApiService, + moshi: Moshi, +) { + + private val errorEnvelopeAdapter: JsonAdapter = + moshi.adapter(ApiErrorEnvelope::class.java) + + /** Callback 401 (expiration/révocation) — branché par AuthViewModel. */ + @Volatile + var onUnauthorized: (() -> Unit)? = null + + suspend fun listFolders(): List = unwrap({ apiService.listFolders() }) + + suspend fun registerDevice(deviceId: String): String = + unwrap({ apiService.registerDevice(DeviceRegistrationDto(deviceId)) }).deviceId + + suspend fun login(username: String, password: String, deviceId: String): LoginResponseDto = + unwrap({ apiService.login(LoginRequestDto(username, password, deviceId)) }, + skipUnauthorizedHandling = true) + + private suspend fun unwrap( + call: suspend () -> Response>, + skipUnauthorizedHandling: Boolean = false, + ): T { + val response = try { + call() + } catch (e: IOException) { + Timber.d(e, "api: transport error") + throw ApiException("NETWORK_ERROR", "Serveur injoignable", 0) + } catch (e: Exception) { + Timber.d(e, "api: unexpected error") + throw ApiException("NETWORK_ERROR", "Serveur injoignable", 0) + } + + if (!response.isSuccessful) { + val status = response.code() + if (status == 401 && !skipUnauthorizedHandling) { + onUnauthorized?.invoke() + } + val error = response.errorBody()?.use { body -> + body.string().let { raw -> + runCatching { errorEnvelopeAdapter.fromJson(raw)?.error }.getOrNull() + } + } + throw ApiException( + code = error?.code ?: "HTTP_$status", + message = error?.message ?: "HTTP $status", + httpCode = status, + ) + } + + val envelope = response.body() + val data = envelope?.data + if (data == null) { + Timber.w("api: 2xx sans enveloppe {data} (%s)", response.code()) + throw ApiException( + code = "INVALID_RESPONSE", + message = "Réponse serveur invalide (enveloppe {data} attendue)", + httpCode = response.code(), + ) + } + return data + } +} \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/remote/ApiService.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/remote/ApiService.kt new file mode 100644 index 0000000..f15b25d --- /dev/null +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/remote/ApiService.kt @@ -0,0 +1,34 @@ +package com.vaultdrop.mobile.data.remote + +import com.vaultdrop.mobile.data.remote.dto.ApiEnvelope +import com.vaultdrop.mobile.data.remote.dto.DeviceRegistrationDto +import com.vaultdrop.mobile.data.remote.dto.FolderDto +import com.vaultdrop.mobile.data.remote.dto.LoginRequestDto +import com.vaultdrop.mobile.data.remote.dto.LoginResponseDto +import retrofit2.Response +import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.POST + +/** + * Contrat HTTP V1 — copie de `mobile/api/client.ts` (le client mobile est la + * source de vérité). Les méthodes renvoient `Response>` pour + * que l'`ApiClient` puisse dissocier succès / erreur contractuelle. + */ +interface ApiService { + + @GET("files/folders") + suspend fun listFolders(): Response>> + + /** Enregistrement idempotent du device — aucun token émis. */ + @POST("devices") + suspend fun registerDevice( + @Body body: DeviceRegistrationDto, + ): Response> + + /** Seule porte d'émission de token (V1). 401 = mauvaises identifiants. */ + @POST("auth/login") + suspend fun login( + @Body body: LoginRequestDto, + ): Response> +} \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/remote/AuthInterceptor.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/remote/AuthInterceptor.kt new file mode 100644 index 0000000..9b5bda3 --- /dev/null +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/remote/AuthInterceptor.kt @@ -0,0 +1,27 @@ +package com.vaultdrop.mobile.data.remote + +import okhttp3.Interceptor +import okhttp3.Response +import javax.inject.Inject +import javax.inject.Singleton +import com.vaultdrop.mobile.auth.TokenProvider + +/** + * Injecte `Authorization: Bearer ` sur chaque requête si un token est + * disponible. Sans token (mode local / non connecté), aucune entête n'est posée + * — le serveur répondra 401, géré proprement par `ApiClient`. + */ +@Singleton +class AuthInterceptor @Inject constructor( + private val tokenProvider: TokenProvider, +) : Interceptor { + + override fun intercept(chain: Interceptor.Chain): Response { + val token = tokenProvider.current + val requestBuilder = chain.request().newBuilder() + if (token != null) { + requestBuilder.header("Authorization", "Bearer $token") + } + return chain.proceed(requestBuilder.build()) + } +} \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/remote/dto/Dtos.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/remote/dto/Dtos.kt new file mode 100644 index 0000000..7d086cd --- /dev/null +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/remote/dto/Dtos.kt @@ -0,0 +1,71 @@ +package com.vaultdrop.mobile.data.remote.dto + +import com.squareup.moshi.Json + + +/** + * Enveloppe API V1 : succès `{ data, meta? }`, erreur `{ error }`. + * Les deux champs étant optionnels, Moshi parse les deux formes dans le même + * type ; c'est l'`ApiClient` qui décide selon le statut HTTP. + */ +data class ApiEnvelope( + @Json(name = "data") val data: T? = null, + @Json(name = "meta") val meta: ApiMeta? = null, + @Json(name = "error") val error: ApiErrorBody? = null, +) + +data class ApiMeta( + @Json(name = "page") val page: Int, + @Json(name = "pageSize") val pageSize: Int, + @Json(name = "total") val total: Int, +) + +/** + * Corps d'erreur normalize — miroir de `ApiErrorBody` JS. + * Le `code` est l'identifiant contractuel (`UNAUTHORIZED`…), jamais le statut HTTP. + */ +data class ApiErrorBody( + @Json(name = "code") val code: String, + @Json(name = "message") val message: String, +) + +/** + * Enveloppe d'erreur du body HTTP (utilisée sur les codes non-2xx, où Retrofit + * expose le corps brut via `errorBody()` sans le convertir). + */ +data class ApiErrorEnvelope( + @Json(name = "error") val error: ApiErrorBody? = null, +) + +/** Miroir de `api/types.ts` — `FolderDto`. */ +data class FolderDto( + @Json(name = "id") val id: String, + @Json(name = "name") val name: String, + @Json(name = "parentId") val parentId: String? = null, +) + +/** Miroir de `api/types.ts` — `DeviceRegistration`. */ +data class DeviceRegistrationDto( + @Json(name = "deviceId") val deviceId: String, +) + +/** Miroir de `api/types.ts` — `User`. */ +data class UserDto( + @Json(name = "id") val id: String, + @Json(name = "username") val username: String, + @Json(name = "is_admin") val isAdmin: Boolean = false, +) + +/** Miroir de `api/types.ts` — `LoginRequest`. */ +data class LoginRequestDto( + @Json(name = "username") val username: String, + @Json(name = "password") val password: String, + @Json(name = "device_id") val deviceId: String, +) + +/** Miroir de `api/types.ts` — `LoginResponse`. */ +data class LoginResponseDto( + @Json(name = "token") val token: String, + @Json(name = "expires_at") val expiresAt: Long, + @Json(name = "user") val user: UserDto, +) \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/repository/AuthRepository.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/repository/AuthRepository.kt new file mode 100644 index 0000000..81b9dbc --- /dev/null +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/repository/AuthRepository.kt @@ -0,0 +1,36 @@ +package com.vaultdrop.mobile.data.repository + +import com.vaultdrop.mobile.data.remote.ApiClient +import com.vaultdrop.mobile.data.remote.dto.LoginResponseDto +import com.vaultdrop.mobile.domain.DeviceIdentity +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Flux d'auth. Le device s'enregistre d'abord (`POST /devices`, idempotent, + * aucun token émis), puis `POST /auth/login` émet le paseto. + */ +@Singleton +class AuthRepository @Inject constructor( + private val apiClient: ApiClient, + private val deviceIdentity: DeviceIdentity, +) { + + /** Enregistrement best-effort du device (offline → ignoré). */ + suspend fun registerDevice(): String { + val deviceId = deviceIdentity.getOrCreate() + runCatching { apiClient.registerDevice(deviceId) } + .onFailure { Timber.d(it, "auth: device registration failed (offline?)") } + return deviceId + } + + suspend fun login(username: String, password: String): LoginResponseDto { + val deviceId = deviceIdentity.getOrCreate() + // Re-registration idempotente juste avant le login : un device inconnu + // du serveur (ex. restart de la base) répondrait INVALID_DEVICE_ID. + runCatching { apiClient.registerDevice(deviceId) } + .onFailure { Timber.d(it, "auth: pre-login registration failed") } + return apiClient.login(username.trim(), password, deviceId) + } +} \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/repository/FolderRepository.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/repository/FolderRepository.kt new file mode 100644 index 0000000..c86217a --- /dev/null +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/data/repository/FolderRepository.kt @@ -0,0 +1,91 @@ +package com.vaultdrop.mobile.data.repository + +import com.vaultdrop.mobile.data.local.dao.FolderDao +import com.vaultdrop.mobile.data.local.entity.FolderEntity +import com.vaultdrop.mobile.data.local.entity.FolderStatus +import com.vaultdrop.mobile.data.remote.ApiClient +import com.vaultdrop.mobile.data.remote.dto.FolderDto +import com.vaultdrop.mobile.domain.GenerateId +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Miroir de `services/db/repositories/folders.ts`. Local-first : l'UI lit Room + * ; le réseau sert de source de rafraîchissement (snapshot cloud). + */ +@Singleton +class FolderRepository @Inject constructor( + private val folderDao: FolderDao, + private val apiClient: ApiClient, + private val generateId: GenerateId, +) { + + fun observeRootFolders(): Flow> = folderDao.observeRootFolders() + + suspend fun getRootFolders(): List = folderDao.getRootFolders() + + suspend fun getFolder(resourceId: String): FolderEntity? = + folderDao.getByResourceId(resourceId) + + /** + * Fetch `GET /files/folders` et upsert dans Room. Les dossiers cloud + * n'ont pas d'uri (uri = NULL → cloud-only). En V1 le serveur ne renvoie + * que les racines. + */ + suspend fun refreshFromServer() { + val folders = apiClient.listFolders() + if (folders.isEmpty()) return + val now = System.currentTimeMillis() + folderDao.upsertAll(folders.map { toEntity(it, now) }) + } + + private suspend fun toEntity(dto: FolderDto, now: Long): FolderEntity { + val existing = folderDao.getByResourceId(dto.id) + return FolderEntity( + resourceId = dto.id, + uri = null, + name = dto.name, + exists = null, + parentResourceId = dto.parentId, + ownerId = existing?.ownerId, + syncStatus = existing?.syncStatus ?: FolderStatus.CLOUD, + addedAt = existing?.addedAt ?: now, + updatedAt = now, + ) + } + + /** + * Upsert local d'un dossier physique (SAF) — établi par UX volontairement : + * ajoute une ligne avec sa `uri` si absente, ou met à jour ses champs. + */ + suspend fun saveFolder( + input: SaveFolderInput, + parentResourceId: String? = null, + ): FolderEntity { + val now = System.currentTimeMillis() + val existing = input.resourceId?.let { folderDao.getByResourceId(it) } + ?: input.uri?.let { folderDao.getByUri(it) } + + val entity = FolderEntity( + resourceId = existing?.resourceId ?: input.resourceId ?: generateId.newResourceId(), + uri = input.uri, + name = input.name, + exists = input.exists?.let { if (it) 1 else 0 }, + parentResourceId = existing?.parentResourceId ?: parentResourceId, + ownerId = existing?.ownerId, + syncStatus = existing?.syncStatus ?: FolderStatus.LOCAL, + addedAt = existing?.addedAt ?: now, + updatedAt = now, + ) + folderDao.upsert(entity) + return entity + } +} + +data class SaveFolderInput( + val uri: String?, + val name: String, + val exists: Boolean? = null, + val resourceId: String? = null, +) \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/di/AppModule.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/di/AppModule.kt new file mode 100644 index 0000000..9af9071 --- /dev/null +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/di/AppModule.kt @@ -0,0 +1,12 @@ +package com.vaultdrop.mobile.di + +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +/** + * Regroupe les bindings transverses (Domain, Ids générés, etc.). + */ +@Module +@InstallIn(SingletonComponent::class) +object AppModule \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/di/DatabaseModule.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/di/DatabaseModule.kt new file mode 100644 index 0000000..21fcb77 --- /dev/null +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/di/DatabaseModule.kt @@ -0,0 +1,32 @@ +package com.vaultdrop.mobile.di + +import android.content.Context +import androidx.room.Room +import com.vaultdrop.mobile.data.local.AppDatabase +import com.vaultdrop.mobile.data.local.dao.FolderDao +import com.vaultdrop.mobile.data.local.dao.UserPreferenceDao +import com.vaultdrop.mobile.data.local.migration.Migrations +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 DatabaseModule { + + @Provides + @Singleton + fun provideDatabase(@ApplicationContext context: Context): AppDatabase = + Room.databaseBuilder(context, AppDatabase::class.java, "dot.db") + .addMigrations(*Migrations.ALL) + .build() + + @Provides + fun provideFolderDao(db: AppDatabase): FolderDao = db.folderDao() + + @Provides + fun provideUserPreferenceDao(db: AppDatabase): UserPreferenceDao = db.userPreferenceDao() +} \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/di/NetworkModule.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/di/NetworkModule.kt new file mode 100644 index 0000000..fa05949 --- /dev/null +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/di/NetworkModule.kt @@ -0,0 +1,60 @@ +package com.vaultdrop.mobile.di + +import com.squareup.moshi.Moshi +import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory +import com.vaultdrop.mobile.BuildConfig +import com.vaultdrop.mobile.data.remote.ApiService +import com.vaultdrop.mobile.data.remote.AuthInterceptor +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 java.util.concurrent.TimeUnit +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object NetworkModule { + + @Provides + @Singleton + fun provideMoshi(): Moshi = Moshi.Builder() + .add(KotlinJsonAdapterFactory()) + .build() + + @Provides + @Singleton + fun provideOkHttpClient(authInterceptor: AuthInterceptor): OkHttpClient { + val logging = HttpLoggingInterceptor().apply { + level = if (BuildConfig.DEBUG) { + HttpLoggingInterceptor.Level.BASIC + } else { + HttpLoggingInterceptor.Level.NONE + } + } + return OkHttpClient.Builder() + .addInterceptor(authInterceptor) + .addInterceptor(logging) + .connectTimeout(15, TimeUnit.SECONDS) + .readTimeout(15, TimeUnit.SECONDS) + .build() + } + + @Provides + @Singleton + fun provideRetrofit(client: OkHttpClient, moshi: Moshi): Retrofit = + Retrofit.Builder() + .baseUrl(BuildConfig.API_BASE_URL.trimEnd('/') + "/") + .client(client) + .addConverterFactory(MoshiConverterFactory.create(moshi)) + .build() + + @Provides + @Singleton + fun provideApiService(retrofit: Retrofit): ApiService = + retrofit.create(ApiService::class.java) +} \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/domain/ActiveUserStore.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/domain/ActiveUserStore.kt new file mode 100644 index 0000000..e015964 --- /dev/null +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/domain/ActiveUserStore.kt @@ -0,0 +1,26 @@ +package com.vaultdrop.mobile.domain + +import com.vaultdrop.mobile.data.local.dao.UserPreferenceDao +import com.vaultdrop.mobile.data.local.entity.UserPreferenceEntity +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Miroir non-sensible du compte connecté (le token vit dans le store sécurisé). + * Permet aux repositories de scoper leurs lectures sans dépendre du store chiffré. + */ +@Singleton +class ActiveUserStore @Inject constructor( + private val userPreferenceDao: UserPreferenceDao, +) { + + suspend fun set(userId: String) { + userPreferenceDao.upsert( + UserPreferenceEntity(PrefKeys.ACTIVE_USER_ID, userId, System.currentTimeMillis()), + ) + } + + suspend fun get(): String? = userPreferenceDao.getValue(PrefKeys.ACTIVE_USER_ID) + + suspend fun clear() = userPreferenceDao.delete(PrefKeys.ACTIVE_USER_ID) +} \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/domain/DeviceIdentity.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/domain/DeviceIdentity.kt new file mode 100644 index 0000000..e4d9f7d --- /dev/null +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/domain/DeviceIdentity.kt @@ -0,0 +1,27 @@ +package com.vaultdrop.mobile.domain + +import com.vaultdrop.mobile.data.local.dao.UserPreferenceDao +import com.vaultdrop.mobile.data.local.entity.UserPreferenceEntity +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Identité locale du device : 32-hex généré une seule fois à la première + * requête, persisté en `user_preferences`. Équivalent de `getDeviceUserId()`. + */ +@Singleton +class DeviceIdentity @Inject constructor( + private val userPreferenceDao: UserPreferenceDao, + private val generateId: GenerateId, +) { + + suspend fun getOrCreate(): String { + userPreferenceDao.getValue(PrefKeys.DEVICE_USER_ID)?.let { return it } + val id = generateId.newResourceId() + userPreferenceDao.insertIgnore( + UserPreferenceEntity(PrefKeys.DEVICE_USER_ID, id, System.currentTimeMillis()), + ) + return userPreferenceDao.getValue(PrefKeys.DEVICE_USER_ID) + ?: generateId.newResourceId() + } +} \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/domain/GenerateId.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/domain/GenerateId.kt new file mode 100644 index 0000000..602e9b5 --- /dev/null +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/domain/GenerateId.kt @@ -0,0 +1,21 @@ +package com.vaultdrop.mobile.domain + +import java.security.SecureRandom +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Identifiants opaques 32-hex minuscule — équivalent de `newResourceId()` JS + * (`lower(hex(randomblob(16)))`). Jamais reconverti en UUID côté serveur. + */ +@Singleton +class GenerateId @Inject constructor() { + + private val random = SecureRandom() + + fun newResourceId(): String { + val bytes = ByteArray(16) + random.nextBytes(bytes) + return bytes.joinToString("") { "%02x".format(it) } + } +} \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/domain/PrefKeys.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/domain/PrefKeys.kt new file mode 100644 index 0000000..fff660e --- /dev/null +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/domain/PrefKeys.kt @@ -0,0 +1,8 @@ +package com.vaultdrop.mobile.domain + +/** Clés de `user_preferences` — miroir de `services/db/schema.ts`. */ +object PrefKeys { + const val DEVICE_USER_ID = "device_user_id" + const val ACTIVE_USER_ID = "active_user_id" + const val SYNC_MODE = "sync_mode" +} \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/auth/AuthState.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/auth/AuthState.kt new file mode 100644 index 0000000..a344f9c --- /dev/null +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/auth/AuthState.kt @@ -0,0 +1,16 @@ +package com.vaultdrop.mobile.ui.auth + +import com.vaultdrop.mobile.data.remote.dto.UserDto + +/** État global de connexion — équivalent de `AuthStatus` JS. */ +sealed interface AuthState { + data object Loading : AuthState + data object SignedOut : AuthState + data object Local : AuthState + data class SignedIn(val user: UserDto) : AuthState +} + +data class LoginUiState( + val isSubmitting: Boolean = false, + val error: String? = null, +) \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/auth/AuthViewModel.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/auth/AuthViewModel.kt new file mode 100644 index 0000000..079229b --- /dev/null +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/auth/AuthViewModel.kt @@ -0,0 +1,86 @@ +package com.vaultdrop.mobile.ui.auth + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.vaultdrop.mobile.auth.SessionManager +import com.vaultdrop.mobile.data.remote.ApiClient +import com.vaultdrop.mobile.data.remote.ApiException +import com.vaultdrop.mobile.data.repository.AuthRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import timber.log.Timber +import javax.inject.Inject + +@HiltViewModel +class AuthViewModel @Inject constructor( + private val authRepository: AuthRepository, + private val sessionManager: SessionManager, + private val apiClient: ApiClient, +) : ViewModel() { + + private val _authState = MutableStateFlow(AuthState.Loading) + val authState: StateFlow = _authState.asStateFlow() + + private val _loginUiState = MutableStateFlow(LoginUiState()) + val loginUiState: StateFlow = _loginUiState.asStateFlow() + + init { + // 401 sur un endpoint protégé (token expiré/révoqué, compte supprimé) + // → purge de la session. Le 401 du login est exonéré (skip dans ApiClient). + apiClient.onUnauthorized = { signOut() } + + viewModelScope.launch { + val session = sessionManager.restore() + _authState.value = + if (session != null) AuthState.SignedIn(session.user) else AuthState.SignedOut + authRepository.registerDevice() + } + } + + fun signIn(username: String, password: String) { + if (username.isBlank() || password.isBlank()) { + _loginUiState.update { it.copy(error = "REQUIRED") } + return + } + viewModelScope.launch { + _loginUiState.update { LoginUiState(isSubmitting = true) } + runCatching { authRepository.login(username, password) } + .onSuccess { response -> + sessionManager.save(response.token, response.user) + _loginUiState.value = LoginUiState() + _authState.value = AuthState.SignedIn(response.user) + } + .onFailure { e -> + val error = when (e) { + is ApiException -> e.code + else -> "GENERIC" + } + _loginUiState.update { it.copy(isSubmitting = false, error = error) } + } + } + } + + fun continueWithoutAccount() { + _authState.value = AuthState.Local + } + + fun signOut() { + viewModelScope.launch { + Timber.d("auth: session purgée (signOut)") + withContext(Dispatchers.IO) { + sessionManager.clear() + } + _authState.value = AuthState.SignedOut + } + } + + override fun onCleared() { + apiClient.onUnauthorized = null + } +} \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/auth/LoginScreen.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/auth/LoginScreen.kt new file mode 100644 index 0000000..c563dcf --- /dev/null +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/auth/LoginScreen.kt @@ -0,0 +1,135 @@ +package com.vaultdrop.mobile.ui.auth + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vaultdrop.mobile.R + +@Composable +fun LoginScreen( + viewModel: AuthViewModel = hiltViewModel(), +) { + val loginUi by viewModel.loginUiState.collectAsStateWithLifecycle() + + var username by rememberSaveable { mutableStateOf("") } + var password by rememberSaveable { mutableStateOf("") } + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .imePadding() + .padding(horizontal = 24.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = stringResource(R.string.app_name), + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.primary, + ) + Spacer(Modifier.height(8.dp)) + Text( + text = stringResource(R.string.login_subtitle), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(32.dp)) + + OutlinedTextField( + value = username, + onValueChange = { username = it }, + label = { Text(stringResource(R.string.login_username)) }, + singleLine = true, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(12.dp)) + OutlinedTextField( + value = password, + onValueChange = { password = it }, + label = { Text(stringResource(R.string.login_password)) }, + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Done, + ), + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(24.dp)) + + Button( + onClick = { viewModel.signIn(username, password) }, + enabled = !loginUi.isSubmitting, + modifier = Modifier.fillMaxWidth(), + ) { + if (loginUi.isSubmitting) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp, + ) + } else { + Text(stringResource(R.string.login_submit)) + } + } + + loginUi.error?.let { error -> + Spacer(Modifier.height(12.dp)) + Text( + text = stringResource(errorResFor(error)), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + textAlign = TextAlign.Center, + ) + } + + Spacer(Modifier.height(16.dp)) + TextButton(onClick = viewModel::continueWithoutAccount) { + Text(stringResource(R.string.login_skip)) + } + Text( + text = stringResource(R.string.skip_message), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } +} + +private fun errorResFor(code: String): Int = when (code) { + "REQUIRED" -> R.string.login_error_required + "UNAUTHORIZED" -> R.string.login_error_unauthorized + else -> R.string.login_error_generic +} \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderlist/FolderListScreen.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderlist/FolderListScreen.kt new file mode 100644 index 0000000..df8808f --- /dev/null +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderlist/FolderListScreen.kt @@ -0,0 +1,141 @@ +package com.vaultdrop.mobile.ui.folderlist + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Folder +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vaultdrop.mobile.R +import com.vaultdrop.mobile.data.local.entity.FolderEntity + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun FolderListScreen( + viewModel: FolderListViewModel = hiltViewModel(), +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.files)) }, + actions = { + IconButton(onClick = viewModel::refresh) { + Icon(Icons.Filled.Refresh, contentDescription = stringResource(R.string.refresh)) + } + }, + ) + }, + ) { padding -> + FolderListContent( + uiState = uiState, + modifier = Modifier.padding(padding), + ) + } +} + +@Composable +private fun FolderListContent( + uiState: FolderListUiState, + modifier: Modifier = Modifier, +) { + when { + uiState.folders.isEmpty() && uiState.isRefreshing -> { + Column( + modifier = modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + CircularProgressIndicator() + } + } + + uiState.folders.isEmpty() -> { + Column( + modifier = modifier + .fillMaxSize() + .padding(24.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = stringResource(R.string.no_folders_yet), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + else -> { + LazyColumn( + modifier = modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + items(uiState.folders, key = { it.resourceId }) { folder -> + FolderRow(folder) + } + } + } + } +} + +@Composable +private fun FolderRow(folder: FolderEntity) { + Card( + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + elevation = CardDefaults.cardElevation(defaultElevation = 1.dp), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon( + imageVector = Icons.Filled.Folder, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(28.dp), + ) + Text( + text = folder.name, + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + fontWeight = FontWeight.Medium, + ) + } + } +} \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderlist/FolderListUiState.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderlist/FolderListUiState.kt new file mode 100644 index 0000000..4bc19f3 --- /dev/null +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderlist/FolderListUiState.kt @@ -0,0 +1,9 @@ +package com.vaultdrop.mobile.ui.folderlist + +import com.vaultdrop.mobile.data.local.entity.FolderEntity + +data class FolderListUiState( + val folders: List = emptyList(), + val isRefreshing: Boolean = false, + val error: String? = null, +) \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderlist/FolderListViewModel.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderlist/FolderListViewModel.kt new file mode 100644 index 0000000..8f77edc --- /dev/null +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/folderlist/FolderListViewModel.kt @@ -0,0 +1,58 @@ +package com.vaultdrop.mobile.ui.folderlist + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.vaultdrop.mobile.auth.TokenProvider +import com.vaultdrop.mobile.data.remote.ApiException +import com.vaultdrop.mobile.data.repository.FolderRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject + +@HiltViewModel +class FolderListViewModel @Inject constructor( + private val folderRepository: FolderRepository, + private val tokenProvider: TokenProvider, +) : ViewModel() { + + private val _uiState = MutableStateFlow(FolderListUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + observeFolders() + refresh() + } + + private fun observeFolders() { + viewModelScope.launch { + folderRepository.observeRootFolders().collect { folders -> + _uiState.update { it.copy(folders = folders) } + } + } + } + + fun refresh() { + viewModelScope.launch { + // Sans token → mode local : on ne tient pas de GET /files/folders. + if (tokenProvider.current == null) { + Timber.d("folders: pas de token, rafraîchissement serveur ignoré") + return@launch + } + _uiState.update { it.copy(isRefreshing = true, error = null) } + runCatching { folderRepository.refreshFromServer() } + .onFailure { e -> + val error = when { + e is ApiException && e.code == "UNAUTHORIZED" -> "AUTH_REQUIRED" + else -> e.message ?: "Erreur réseau" + } + _uiState.update { it.copy(error = error) } + } + _uiState.update { it.copy(isRefreshing = false) } + } + } +} \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/navigation/NavGraph.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/navigation/NavGraph.kt new file mode 100644 index 0000000..17f88a0 --- /dev/null +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/navigation/NavGraph.kt @@ -0,0 +1,24 @@ +package com.vaultdrop.mobile.ui.navigation + +import androidx.compose.runtime.Composable +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import com.vaultdrop.mobile.ui.folderlist.FolderListScreen + +object Routes { + const val FILES = "files" +} + +@Composable +fun NavGraph() { + val navController = rememberNavController() + NavHost( + navController = navController, + startDestination = Routes.FILES, + ) { + composable(Routes.FILES) { + FolderListScreen() + } + } +} \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/navigation/VaultDropApp.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/navigation/VaultDropApp.kt new file mode 100644 index 0000000..9da5ed9 --- /dev/null +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/navigation/VaultDropApp.kt @@ -0,0 +1,44 @@ +package com.vaultdrop.mobile.ui.navigation + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vaultdrop.mobile.ui.auth.AuthState +import com.vaultdrop.mobile.ui.auth.AuthViewModel +import com.vaultdrop.mobile.ui.auth.LoginScreen + +@Composable +private fun SplashScreen() { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + CircularProgressIndicator() + } +} + +/** + * Gate racine : restaure la session puis oriente (login si signé out, + * navigation principale si signé in / mode local). + */ +@Composable +fun VaultDropApp( + authViewModel: AuthViewModel = hiltViewModel(), +) { + val authState by authViewModel.authState.collectAsStateWithLifecycle() + + when (authState) { + AuthState.Loading -> SplashScreen() + AuthState.SignedOut -> LoginScreen() + AuthState.Local -> NavGraph() + is AuthState.SignedIn -> NavGraph() + } +} \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/theme/Color.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/theme/Color.kt new file mode 100644 index 0000000..14cab94 --- /dev/null +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/theme/Color.kt @@ -0,0 +1,9 @@ +package com.vaultdrop.mobile.ui.theme + +import androidx.compose.ui.graphics.Color + +val BrandBlue = Color(0xFF1A73E8) +val BondBlueLight = Color(0xFFD2E3FC) +val InactiveGray = Color(0xFF6B7280) +val CardBackground = Color(0xFFF7F8FA) +val CardBorder = Color(0xFFEAEAEA) \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/theme/Theme.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/theme/Theme.kt new file mode 100644 index 0000000..6cd0c1f --- /dev/null +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/theme/Theme.kt @@ -0,0 +1,34 @@ +package com.vaultdrop.mobile.ui.theme + +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable + +private val LightColors = lightColorScheme( + primary = BrandBlue, + onPrimary = androidx.compose.ui.graphics.Color.White, + secondary = InactiveGray, + surfaceVariant = CardBackground, +) + +private val DarkColors = darkColorScheme( + primary = BondBlueLight, + onPrimary = androidx.compose.ui.graphics.Color.Black, +) + +@Composable +fun VaultDropTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + content: @Composable () -> Unit, +) { + MaterialTheme( + colorScheme = if (darkTheme) DarkColors else LightColors, + typography = Typography, + content = content, + ) +} + +@Composable +private fun isSystemInDarkTheme(): Boolean = + androidx.compose.foundation.isSystemInDarkTheme() \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/theme/Type.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/theme/Type.kt new file mode 100644 index 0000000..8dc227f --- /dev/null +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/theme/Type.kt @@ -0,0 +1,24 @@ +package com.vaultdrop.mobile.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +val Typography = Typography( + bodyLarge = TextStyle( + fontSize = 16.sp, + lineHeight = 24.sp, + fontWeight = FontWeight.Normal, + ), + titleMedium = TextStyle( + fontSize = 16.sp, + lineHeight = 24.sp, + fontWeight = FontWeight.SemiBold, + ), + labelMedium = TextStyle( + fontSize = 12.sp, + lineHeight = 16.sp, + fontWeight = FontWeight.Medium, + ), +) \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/res/drawable/ic_launcher_background.xml b/mobile-kotlin/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..ac79227 --- /dev/null +++ b/mobile-kotlin/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/res/drawable/ic_launcher_foreground.xml b/mobile-kotlin/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..21c5311 --- /dev/null +++ b/mobile-kotlin/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,13 @@ + + + + + \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/mobile-kotlin/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..eca70cf --- /dev/null +++ b/mobile-kotlin/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/res/values-en/strings.xml b/mobile-kotlin/app/src/main/res/values-en/strings.xml new file mode 100644 index 0000000..a2f52df --- /dev/null +++ b/mobile-kotlin/app/src/main/res/values-en/strings.xml @@ -0,0 +1,25 @@ + + VaultDrop + + Files + Folder + Add a folder + No folders yet. Tap "Add a folder" to sync a folder. + No files yet. Tap "Add a folder" to sync your files. + Empty folder — next sync will refresh it. + Could not load folders + Refresh + + + Sign in + Sign in to your account + Username + Password + Sign in + Signing in… + Username and password are required + Could not sign in right now + Incorrect username or password + Continue without account + Local mode — your folders stay on this device and are not synced. + \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/res/values/strings.xml b/mobile-kotlin/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..8d47d45 --- /dev/null +++ b/mobile-kotlin/app/src/main/res/values/strings.xml @@ -0,0 +1,26 @@ + + VaultDrop + + + Fichiers + Dossier + Ajouter un dossier + Aucun dossier pour le moment. Appuie sur « Ajouter un dossier » pour synchroniser un dossier. + Aucun fichier pour le moment. Appuie sur « Ajouter un dossier » pour synchroniser tes fichiers. + Dossier vide — le prochain syncDevice l\'actualisera. + Impossible de charger les dossiers + Actualiser + + + Connexion + Connecte-toi à ton compte + Nom d\'utilisateur + Mot de passe + Se connecter + Connexion… + Nom d\'utilisateur et mot de passe requis + Impossible de se connecter pour le moment + Nom d\'utilisateur ou mot de passe incorrect + Continuer sans compte + Mode local — tes dossiers restent sur cet appareil et ne sont pas synchronisés. + \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/res/values/themes.xml b/mobile-kotlin/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..645253d --- /dev/null +++ b/mobile-kotlin/app/src/main/res/values/themes.xml @@ -0,0 +1,3 @@ + +