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 @@
+
+
+
\ No newline at end of file
diff --git a/mobile-kotlin/build.gradle.kts b/mobile-kotlin/build.gradle.kts
new file mode 100644
index 0000000..c5a2d54
--- /dev/null
+++ b/mobile-kotlin/build.gradle.kts
@@ -0,0 +1,7 @@
+plugins {
+ alias(libs.plugins.android.application) apply false
+ alias(libs.plugins.kotlin.android) apply false
+ alias(libs.plugins.kotlin.compose) apply false
+ alias(libs.plugins.ksp) apply false
+ alias(libs.plugins.hilt) apply false
+}
\ No newline at end of file
diff --git a/mobile-kotlin/gradle.properties b/mobile-kotlin/gradle.properties
new file mode 100644
index 0000000..2538e8d
--- /dev/null
+++ b/mobile-kotlin/gradle.properties
@@ -0,0 +1,6 @@
+org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 -XX:+UseParallelGC
+org.gradle.parallel=true
+org.gradle.caching=true
+android.useAndroidX=true
+kotlin.code.style=official
+android.nonTransitiveRClass=true
\ No newline at end of file
diff --git a/mobile-kotlin/gradle/libs.versions.toml b/mobile-kotlin/gradle/libs.versions.toml
new file mode 100644
index 0000000..5244860
--- /dev/null
+++ b/mobile-kotlin/gradle/libs.versions.toml
@@ -0,0 +1,53 @@
+[versions]
+agp = "8.7.3"
+kotlin = "2.0.21"
+ksp = "2.0.21-1.0.27"
+coreKtx = "1.15.0"
+lifecycle = "2.8.7"
+activityCompose = "1.9.3"
+composeBom = "2024.12.01"
+navigationCompose = "2.8.5"
+room = "2.6.1"
+hilt = "2.53.1"
+hiltNavigationCompose = "1.2.0"
+retrofit = "2.11.0"
+okhttp = "4.12.0"
+moshi = "1.15.1"
+securityCrypto = "1.0.0"
+timber = "5.0.1"
+
+[libraries]
+androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
+androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" }
+androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" }
+androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" }
+androidx-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-material3 = { group = "androidx.compose.material3", name = "material3" }
+androidx-compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" }
+androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
+androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
+androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
+androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
+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" }
+androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hiltNavigationCompose" }
+retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
+retrofit-converter-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", version.ref = "moshi" }
+moshi-kotlin = { group = "com.squareup.moshi", name = "moshi-kotlin", version.ref = "moshi" }
+androidx-security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "securityCrypto" }
+timber = { group = "com.jakewharton.timber", name = "timber", version.ref = "timber" }
+
+[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" }
+ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
+hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
\ No newline at end of file
diff --git a/mobile-kotlin/gradle/wrapper/gradle-wrapper.jar b/mobile-kotlin/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..a4b76b9
Binary files /dev/null and b/mobile-kotlin/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/mobile-kotlin/gradle/wrapper/gradle-wrapper.properties b/mobile-kotlin/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..8fc91c8
--- /dev/null
+++ b/mobile-kotlin/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,7 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
+networkTimeout=10000
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
\ No newline at end of file
diff --git a/mobile-kotlin/gradlew b/mobile-kotlin/gradlew
new file mode 100755
index 0000000..d95bf61
--- /dev/null
+++ b/mobile-kotlin/gradlew
@@ -0,0 +1,252 @@
+#!/bin/sh
+
+#
+# Copyright © 2015-2021 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
+' "$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=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+
+# 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='-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"'
+
+# Collect all arguments for the java command:
+# * DEFAULT_JVM_OPTS, JAVA_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" \
+ org.gradle.wrapper.GradleWrapperMain \
+ "$@"
+
+# 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" "$@"
diff --git a/mobile-kotlin/gradlew.bat b/mobile-kotlin/gradlew.bat
new file mode 100644
index 0000000..640d686
--- /dev/null
+++ b/mobile-kotlin/gradlew.bat
@@ -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=-Dfile.encoding=UTF-8 "-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=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
+
+: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
diff --git a/mobile-kotlin/settings.gradle.kts b/mobile-kotlin/settings.gradle.kts
new file mode 100644
index 0000000..d93eae6
--- /dev/null
+++ b/mobile-kotlin/settings.gradle.kts
@@ -0,0 +1,24 @@
+pluginManagement {
+ repositories {
+ google {
+ content {
+ includeGroupByRegex("com\\.android.*")
+ includeGroupByRegex("com\\.google.*")
+ includeGroupByRegex("androidx.*")
+ }
+ }
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+rootProject.name = "VaultDrop"
+include(":app")
\ No newline at end of file