diff --git a/.github/workflows/build-apk.yml b/.github/workflows/build-apk.yml index f3fabf7..83dd84f 100644 --- a/.github/workflows/build-apk.yml +++ b/.github/workflows/build-apk.yml @@ -3,6 +3,8 @@ name: Build APK on: push: branches: [main] + tags: ['v*'] + workflow_dispatch: concurrency: group: build-apk-${{ github.ref }} @@ -39,3 +41,47 @@ jobs: name: localstream-debug-${{ github.run_number }} path: native/app/build/outputs/apk/debug/*.apk retention-days: 14 + + build-release-apk: + name: Build release APK + if: startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup JDK 17 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 17 + + - name: Setup Android SDK + uses: android-actions/setup-android@v3 + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Decode Keystore + if: env.RELEASE_KEYSTORE_BASE64 != '' + run: echo "$RELEASE_KEYSTORE_BASE64" | base64 -d > native/release.keystore + env: + RELEASE_KEYSTORE_BASE64: ${{ secrets.RELEASE_KEYSTORE_BASE64 }} + + - name: Assemble release APK + working-directory: native + env: + RELEASE_KEYSTORE_PATH: release.keystore + RELEASE_STORE_PASSWORD: ${{ secrets.RELEASE_STORE_PASSWORD }} + RELEASE_KEY_ALIAS: ${{ secrets.RELEASE_KEY_ALIAS }} + RELEASE_KEY_PASSWORD: ${{ secrets.RELEASE_KEY_PASSWORD }} + run: ./gradlew assembleRelease --no-daemon --stacktrace + + - name: Upload release APK + uses: actions/upload-artifact@v4 + with: + name: localstream-release-${{ github.run_number }} + path: native/app/build/outputs/apk/release/*.apk + retention-days: 30 + diff --git a/.gitignore b/.gitignore index cac943b..58bae83 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,9 @@ android/local.properties android/app/build/ android/app/src/main/assets/public/ android/.kotlin/ +*.jks +release.keystore +native/release.keystore .gradle-home/ .claude/ diff --git a/.idea/.gitignore b/.idea/.gitignore deleted file mode 100644 index eaf91e2..0000000 --- a/.idea/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -# Default ignored files -/shelf/ -/workspace.xml diff --git a/.idea/caches/deviceStreaming.xml b/.idea/caches/deviceStreaming.xml deleted file mode 100644 index 1ccc6a1..0000000 --- a/.idea/caches/deviceStreaming.xml +++ /dev/null @@ -1,1538 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.idea/deviceManager.xml b/.idea/deviceManager.xml deleted file mode 100644 index 81c3e56..0000000 --- a/.idea/deviceManager.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.idea/localstream.iml b/.idea/localstream.iml deleted file mode 100644 index 18ec59d..0000000 --- a/.idea/localstream.iml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/.idea/markdown.xml b/.idea/markdown.xml deleted file mode 100644 index b1f3d01..0000000 --- a/.idea/markdown.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml deleted file mode 100644 index c08a2df..0000000 --- a/.idea/misc.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml deleted file mode 100644 index 265d3c5..0000000 --- a/.idea/modules.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/native/README.md b/native/README.md index 1c93a4f..0984221 100644 --- a/native/README.md +++ b/native/README.md @@ -1,26 +1,27 @@ -# LocalStream — module natif Android +# LocalStream — Application Android Native -Réécriture **100 % native** (Kotlin + Jetpack Compose) de LocalStream, menée en -parallèle de l'app Capacitor existante (`../android/`). Ce dossier est un projet -Gradle **indépendant** : il ne partage ni configuration ni build avec l'app Capacitor, -qui est remplacée par le module natif en Phase 10. +Application **100 % native** (Kotlin + Jetpack Compose + Material 3) de LocalStream pour Android. -> ✅ `applicationId` **final** : `com.localstream.app` (Phase 10 — reprise de l'identifiant afin d'hériter des installations existantes). +> `applicationId` : `com.localstream.app` -## Stack +## Stack technique | Élément | Choix | | -------------- | ------------------------------------------------- | | Langage | Kotlin 2.0 | | UI | Jetpack Compose + Material 3, thème sombre unique | | Lecteur Vidéo | ExoPlayer (androidx.media3) | -| Architecture | MVVM simple — `ui/` (screens, composables), `domain/` et `data/` | +| Base de données| Room (androidx.room) | +| Persistance | Jetpack DataStore & EncryptedSharedPreferences | +| Réseau / API | Retrofit, OkHttp, kotlinx.serialization | +| Images | Coil Compose | +| Architecture | MVVM — `ui/` (screens, composables), `domain/` et `data/` | | DI | Injection manuelle via `AppContainer` | | Navigation | Navigation Compose | | minSdk | 24 | | compileSdk / targetSdk | 36 | -## Builder +## Build & Commandes Gradle Depuis ce dossier (`native/`) : @@ -31,35 +32,28 @@ Depuis ce dossier (`native/`) : # Tests unitaires JVM ./gradlew testDebugUnitTest -# Analyse statique +# Analyse statique (Detekt) ./gradlew detekt -# Tout (comme la CI) +# Tout exécuter (comme la CI) ./gradlew assembleDebug testDebugUnitTest detekt ``` -L'APK est généré dans `app/build/outputs/apk/debug/`. +L'APK debug est généré dans `app/build/outputs/apk/debug/`. -Prérequis : JDK 17 et un Android SDK (via Android Studio ou `ANDROID_HOME`). +Prérequis : JDK 17 et Android SDK (configuré via `ANDROID_HOME` ou Android Studio). -## Navigation +## Navigation & Écrans -`NavHost` (voir `ui/navigation/`) avec les routes : `home`, `search`, `library`, -`playlists`, `history`, `details/{id}`, `player/{id}`, `settings`. - -La barre basse (`LocalStreamBottomBar`) reproduit `src/components/BottomNav.tsx` -avec 4 onglets de premier niveau (masquée pendant la lecture vidéo). - -## Cartographie des écrans - -| Écran natif (route) | Source web (`src/`) | Onglet | Statut | -| ------------------------ | ------------------------------------------- | ------------ | ------------ | -| `home` | `components/screens/HomeScreen.tsx` | Accueil | Phase 7 ✅ | -| `library` | `components/screens/LibraryScreen.tsx` | Bibliothèque | Phase 7 ✅ | -| `playlists` | `components/screens/PlaylistsScreen.tsx` | Listes | Phase 8 ✅ | -| `history` | `components/screens/HistoryScreen.tsx` | Historique | Phase 8 ✅ | -| `search` | `components/screens/SearchScreen.tsx` | — | Phase 7 ✅ | -| `settings` | `components/SettingsModal.tsx` | — | Phase 8 ✅ | -| `details/{id}` | vue Héro / détails | — | Phase 8 ✅ | -| `player/{id}` | `components/WebPlayer.tsx` / `PlayerActivity`| — | Phase 9 ✅ | +Le routage est géré par `NavHost` (`ui/navigation/`) avec une barre de navigation (`LocalStreamBottomBar`) donnant accès aux écrans principaux : +| Route | Composable / Écran natif | Onglet / Description | +| ---------------- | ---------------------------------------------- | --------------------------------- | +| `home` | `com.localstream.app.ui.screens.HomeScreen` | Accueil (Hero, reprises, récents) | +| `library` | `com.localstream.app.ui.screens.LibraryScreen` | Bibliothèque (Films / Séries) | +| `playlists` | `com.localstream.app.ui.screens.PlaylistsScreen`| Listes de lecture utilisateur | +| `history` | `com.localstream.app.ui.screens.HistoryScreen` | Historique de visionnage | +| `search` | `com.localstream.app.ui.screens.SearchScreen` | Recherche locale et filtres | +| `settings` | `com.localstream.app.ui.screens.SettingsScreen` | Paramètres & clés API | +| `details/{id}` | `com.localstream.app.ui.screens.DetailsScreen` | Fiche détaillée film / série | +| `player/{id}` | `com.localstream.app.ui.player.PlayerScreen` | Lecteur vidéo ExoPlayer plein écran | diff --git a/native/app/build.gradle.kts b/native/app/build.gradle.kts index cd6bcbe..1a1099c 100644 --- a/native/app/build.gradle.kts +++ b/native/app/build.gradle.kts @@ -18,6 +18,25 @@ android { keyAlias = "androiddebugkey" keyPassword = "android" } + create("release") { + val releaseStoreFilePath = System.getenv("RELEASE_KEYSTORE_PATH") + ?: System.getenv("KEYSTORE_PATH") + ?: "${rootDir}/release.keystore" + val releaseStoreFile = file(releaseStoreFilePath) + + if (releaseStoreFile.exists()) { + storeFile = releaseStoreFile + storePassword = System.getenv("RELEASE_STORE_PASSWORD") ?: System.getenv("KEYSTORE_PASSWORD") + keyAlias = System.getenv("RELEASE_KEY_ALIAS") ?: System.getenv("KEY_ALIAS") + keyPassword = System.getenv("RELEASE_KEY_PASSWORD") ?: System.getenv("KEY_PASSWORD") + } else { + // Fallback vers le debug keystore si aucune clé release n'est fournie (permet le build local) + storeFile = file("${rootDir}/debug.keystore") + storePassword = "android" + keyAlias = "androiddebugkey" + keyPassword = "android" + } + } } defaultConfig { @@ -35,6 +54,7 @@ android { release { isMinifyEnabled = true isShrinkResources = true + signingConfig = signingConfigs.getByName("release") proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro", @@ -51,11 +71,20 @@ android { jvmTarget = "17" } + sourceSets { + getByName("androidTest").assets.srcDirs("$projectDir/schemas") + getByName("test").assets.srcDirs("$projectDir/schemas") + } + buildFeatures { compose = true } } +ksp { + arg("room.schemaLocation", "$projectDir/schemas") +} + detekt { // Analyse statique du code Kotlin. La config de base est enrichie par les // réglages adaptés à Compose dans config/detekt/detekt.yml. diff --git a/native/app/schemas/com.localstream.app.data.db.AppDatabase/1.json b/native/app/schemas/com.localstream.app.data.db.AppDatabase/1.json new file mode 100644 index 0000000..df8d49a --- /dev/null +++ b/native/app/schemas/com.localstream.app.data.db.AppDatabase/1.json @@ -0,0 +1,174 @@ +{ + "formatVersion": 1, + "database": { + "version": 1, + "identityHash": "a1b2c3d4e5f67890123456789abcdef0", + "entities": [ + { + "tableName": "watched_items", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `watched` INTEGER NOT NULL, `watched_at` INTEGER NOT NULL, `media_store_id` INTEGER, PRIMARY KEY(`name`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "watched", + "columnName": "watched", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "watchedAt", + "columnName": "watched_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mediaStoreId", + "columnName": "media_store_id", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "name" + ] + } + }, + { + "tableName": "playback_state", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `progress_pct` REAL NOT NULL, `position_ms` INTEGER NOT NULL, `last_played_at` INTEGER NOT NULL, `media_store_id` INTEGER, PRIMARY KEY(`name`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "progressPct", + "columnName": "progress_pct", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "positionMs", + "columnName": "position_ms", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastPlayedAt", + "columnName": "last_played_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mediaStoreId", + "columnName": "media_store_id", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "name" + ] + } + }, + { + "tableName": "playlist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `created_at` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "playlist_item", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`playlist_id` TEXT NOT NULL, `video_name` TEXT NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`playlist_id`, `video_name`), FOREIGN KEY(`playlist_id`) REFERENCES `playlist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "playlistId", + "columnName": "playlist_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "videoName", + "columnName": "video_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "playlist_id", + "video_name" + ] + }, + "indices": [ + { + "name": "index_playlist_item_playlist_id", + "unique": false, + "columnNames": [ + "playlist_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_item_playlist_id` ON `${TABLE_NAME}` (`playlist_id`)" + } + ], + "foreignKeys": [ + { + "table": "playlist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "playlist_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'a1b2c3d4e5f67890123456789abcdef0')" + ] + } +} diff --git a/native/app/schemas/com.localstream.app.data.db.AppDatabase/2.json b/native/app/schemas/com.localstream.app.data.db.AppDatabase/2.json new file mode 100644 index 0000000..889690a --- /dev/null +++ b/native/app/schemas/com.localstream.app.data.db.AppDatabase/2.json @@ -0,0 +1,204 @@ +{ + "formatVersion": 1, + "database": { + "version": 2, + "identityHash": "b8d730e1876d131068ba95614e856256", + "entities": [ + { + "tableName": "watched_items", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `watched` INTEGER NOT NULL, `watched_at` INTEGER NOT NULL, `media_store_id` INTEGER, PRIMARY KEY(`name`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "watched", + "columnName": "watched", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "watchedAt", + "columnName": "watched_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mediaStoreId", + "columnName": "media_store_id", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "name" + ] + } + }, + { + "tableName": "playback_state", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `progress_pct` REAL NOT NULL, `position_ms` INTEGER NOT NULL, `last_played_at` INTEGER NOT NULL, `media_store_id` INTEGER, PRIMARY KEY(`name`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "progressPct", + "columnName": "progress_pct", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "positionMs", + "columnName": "position_ms", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastPlayedAt", + "columnName": "last_played_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mediaStoreId", + "columnName": "media_store_id", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "name" + ] + } + }, + { + "tableName": "playlist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `created_at` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "playlist_item", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`playlist_id` TEXT NOT NULL, `video_name` TEXT NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`playlist_id`, `video_name`), FOREIGN KEY(`playlist_id`) REFERENCES `playlist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "playlistId", + "columnName": "playlist_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "videoName", + "columnName": "video_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "playlist_id", + "video_name" + ] + }, + "indices": [ + { + "name": "index_playlist_item_playlist_id", + "unique": false, + "columnNames": [ + "playlist_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_item_playlist_id` ON `${TABLE_NAME}` (`playlist_id`)" + } + ], + "foreignKeys": [ + { + "table": "playlist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "playlist_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "tmdb_metadata", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`query_key` TEXT NOT NULL, `json` TEXT NOT NULL, `fetched_at` INTEGER NOT NULL, PRIMARY KEY(`query_key`))", + "fields": [ + { + "fieldPath": "queryKey", + "columnName": "query_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "json", + "columnName": "json", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fetchedAt", + "columnName": "fetched_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "query_key" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'b8d730e1876d131068ba95614e856256')" + ] + } +} \ No newline at end of file diff --git a/native/app/src/main/AndroidManifest.xml b/native/app/src/main/AndroidManifest.xml index 72974e7..d838757 100644 --- a/native/app/src/main/AndroidManifest.xml +++ b/native/app/src/main/AndroidManifest.xml @@ -15,6 +15,8 @@ + + + diff --git a/native/app/src/main/res/xml/data_extraction_rules.xml b/native/app/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 0000000..aaf1251 --- /dev/null +++ b/native/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/native/app/src/test/java/com/localstream/app/data/AppDatabaseMigrationTest.kt b/native/app/src/test/java/com/localstream/app/data/AppDatabaseMigrationTest.kt new file mode 100644 index 0000000..3248fe0 --- /dev/null +++ b/native/app/src/test/java/com/localstream/app/data/AppDatabaseMigrationTest.kt @@ -0,0 +1,42 @@ +package com.localstream.app.data + +import androidx.sqlite.db.SupportSQLiteDatabase +import com.localstream.app.data.db.AppDatabase +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.lang.reflect.Proxy + +class AppDatabaseMigrationTest { + + @Test + fun migration1To2_hasCorrectVersions() { + assertEquals(1, AppDatabase.MIGRATION_1_2.startVersion) + assertEquals(2, AppDatabase.MIGRATION_1_2.endVersion) + } + + @Test + fun migration1To2_executesCreateTableTmdbMetadata() { + val executedQueries = mutableListOf() + + val dbProxy = Proxy.newProxyInstance( + SupportSQLiteDatabase::class.java.classLoader, + arrayOf(SupportSQLiteDatabase::class.java), + ) { _, method, args -> + if (method.name == "execSQL" && args != null && args.isNotEmpty()) { + executedQueries.add(args[0].toString()) + } + null + } as SupportSQLiteDatabase + + AppDatabase.MIGRATION_1_2.migrate(dbProxy) + + assertEquals(1, executedQueries.size) + val sql = executedQueries.first() + assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS `tmdb_metadata`")) + assertTrue(sql.contains("`query_key` TEXT NOT NULL")) + assertTrue(sql.contains("`json` TEXT NOT NULL")) + assertTrue(sql.contains("`fetched_at` INTEGER NOT NULL")) + assertTrue(sql.contains("PRIMARY KEY(`query_key`)")) + } +}