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`)"))
+ }
+}