feat: add movies stats screen and improve provider sync
- Add MoviesScreen and corresponding navigation - Implement movie data aggregation in ViewingDao and repository - Enhance NetflixParser to support multiple JSON payload shapes - Apply custom app theme and update launcher icons - Add back navigation handling to ProviderSyncScreen
This commit is contained in:
parent
dce3b2258b
commit
f55ff411c6
20 changed files with 1086 additions and 143 deletions
|
|
@ -0,0 +1,63 @@
|
||||||
|
# Plan: Filme-Tab + Web-Sync-Reparatur + Navigations-Bug
|
||||||
|
|
||||||
|
Drei unabhängige Fixes, alle ohne Datenbank-Migration (Room `fallbackToDestructiveMigration` ist aktiv, Schema bleibt v1, nur neue Queries).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fix 1: Filme als eigener Tab in der Bottom-Navi
|
||||||
|
|
||||||
|
Aktuell ist das Dashboard "Filme"-Zählen kaputt (`itemCount - topShows.episodeCount` mit `LIMIT 20`) und Filme tauchen im Serien-Ranking auf.
|
||||||
|
|
||||||
|
**1a. Neue DAO-Queries** (`ViewingDao.kt`) — Room speichert `contentType` als TEXT (`'MOVIE'`/`'SERIES'`), daher String-Filter:
|
||||||
|
- `getTopMovies(limit)` → `SELECT ... WHERE contentType = 'MOVIE' GROUP BY showTitle ORDER BY totalMinutes DESC`
|
||||||
|
- `getTopShows(limit)` → ergänze `WHERE contentType != 'MOVIE'` damit Filme aus dem Serien-Ranking verschwinden
|
||||||
|
- `getMoviesCount()` → `SELECT COUNT(*) ... WHERE contentType = 'MOVIE'`
|
||||||
|
- `getSeriesEpisodesCount()` → `SELECT COUNT(*) ... WHERE contentType != 'MOVIE'`
|
||||||
|
|
||||||
|
**1b. `WatchStats` korrekt befüllen** (`StatsRepository.kt`) — `combine` von 7 Flows (Vararg-Variante wie bereits vorhanden), echte Counts statt Heuristik. `moviesCount` und `seriesEpisodesCount` direkt aus der DB.
|
||||||
|
|
||||||
|
**1c. Neue Datenmodelle** (`WatchStats.kt`) — `MovieSummary` (analog `ShowSummary`) + `topMovies: List<MovieSummary>`-Feld in `WatchStats`.
|
||||||
|
|
||||||
|
**1d. Neuer Screen `MoviesScreen.kt`** — Clone von `SeriesStatsScreen.kt` mit `MovieRankItem` (zeigt Dauer statt Folgenanzahl, Movie-Icon statt "X Folgen"). Wiederverwendet `ProviderBadge`.
|
||||||
|
|
||||||
|
**1e. Bottom-Navi erweitern** (`MainActivity.kt`) — Neuer `Screen.Movies("movies", "Filme", Icons.Default.Movie)` zwischen Series und Analytics. `NavHost` erhält die neue Route.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fix 2: Web-Sync JS-Extraktion reparieren
|
||||||
|
|
||||||
|
Root Cause: Der Button nimmt die **komplette HTML-Seite** (`document.documentElement.outerHTML`) und füttert sie dem CSV-Parser → 1 Müll-Eintrag mit `<meta>`-Tag-Text.
|
||||||
|
|
||||||
|
Recherche zeigt: DOM-Klassen sind bei Netflix gehasht (brittel). Der robuste Weg ist die **Netflix Shakti-API** aus dem WebView heraus, da die Session-Cookies (`NetflixId`) automatisch vorliegen.
|
||||||
|
|
||||||
|
**2a. Provider-spezifische JS-Extraktion** (`ProviderSyncScreen.kt` → `WebViewSyncSection`):
|
||||||
|
- **Netflix**: Injiziere JS, das `window.netflix.reactContext.models.serverDefs.data.BUILD_IDENTIFIER` + `authURL` liest, dann `fetch('/api/shakti/'+buildId+'/viewingactivity?pg='+n)` paginiert (Schleife bis `vhSize` erreicht) und ein sauberes JSON-Array von `{title, seriesTitle, episodeTitle, date, duration}` zurückgibt.
|
||||||
|
- **Prime Video**: Parse inline-JSON aus `script[type="text/template"]` (`props`→widgets→`content.content.titles[]`), fallback auf `[data-automation-id^="wh-date"]`.
|
||||||
|
- **Andere (Disney+/Apple)**: Greaceful-Degradation-Meldung "Web-Sync noch nicht unterstützt, bitte CSV verwenden".
|
||||||
|
|
||||||
|
**2b. Toast-Feedback statt HTML-String**: Der Button zeigt Fortschritt ("Lade Seite N…"). Die extrahierten JSON-Daten gehen an `viewModel.importScrapedWebHistory(json, provider)` — bereits vorhanden, leitet an `NetflixParser.parseJsonApiPayload` weiter.
|
||||||
|
|
||||||
|
**2c. `parseJsonApiPayload` robuster machen** (`NetflixParser.kt`): Akzeptiert jetzt sowohl das Netflix-`viewedItems`-Array **als auch** ein flaches Array (für den Prime-Fall). Field-Reading defensiv (`seriesTitle` für Episoden, `title` für Filme → korrekter `contentType`). Dauer aus dem `duration`-Feld (ms → Minuten) falls vorhanden, sonst Default.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fix 3: Navigations-Bug (hängt nach "Verlauf Auslesen" fest)
|
||||||
|
|
||||||
|
Symptom: Nach dem Sync-Screen kommt man nicht mehr aufs Dashboard. Ursachen: WebView wird nicht disposed, `update`-Lambda erzwingt Reloads, und das alte Parsing der kompletten HTML lief auf dem Main-Thread.
|
||||||
|
|
||||||
|
**3a. WebView sauber lifecycle-gebunden** (`WebViewSyncSection`):
|
||||||
|
- `DisposableEffect` / `remember { ...; onDispose { webView.destroy() } }` statt freischwebendem Ref — verhindert, dass der WebView den Screen blockiert.
|
||||||
|
- Reload-Schleife in `update`-Lambda entfernen (vergleicht nur noch, ob sich der Provider geändert hat, nicht jeden Frame).
|
||||||
|
|
||||||
|
**3b. Parsing off-main**: Die extrahierten JSON-Daten werden ohnehin über `viewModelScope.launch(Dispatchers.IO)` verarbeitet (bereits in `importScrapedWebHistory`), aber das JS-`evaluateJavascript`-Ergebnis wird vor dem Parsen als String geprüft — keine riesige HTML-Seite mehr, die den Main-Thread blockiert.
|
||||||
|
|
||||||
|
**3c. Navi-Zurück-Verhalten**: `BackHandler` im Sync-Screen, der bei geladenem WebView zuerst `webView.goBack()` macht (falls History vorhanden) und erst dann `navController.popBackStack()`. So kommt man sicher vom Sync-Screen weg.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Validierung
|
||||||
|
- `./gradlew assembleDebug` (Java 21) baut fehlerfrei.
|
||||||
|
- `adb install -r` auf das S24 Ultra.
|
||||||
|
- Manueller Smoke-Test: Sample-Daten → Filme-Tab zeigt Filme, Serien-Tab nur Serien, Dashboard zählt korrekt. Navi nach Sync funktioniert.
|
||||||
|
|
||||||
|
Alle drei Fixes sind voneinander unabhängig und werden in einem Build-Zyklus umgesetzt. Keine DB-Schema-Änderung (nur neue Queries auf bestehenden Spalten).
|
||||||
|
|
@ -12,13 +12,13 @@
|
||||||
android:label="BingeStats"
|
android:label="BingeStats"
|
||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@android:style/Theme.Material.NoTitleBar"
|
android:theme="@style/Theme.BingeStats"
|
||||||
android:usesCleartextTraffic="true">
|
android:usesCleartextTraffic="true">
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:theme="@android:style/Theme.Material.NoTitleBar">
|
android:theme="@style/Theme.BingeStats">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Analytics
|
import androidx.compose.material.icons.filled.Analytics
|
||||||
import androidx.compose.material.icons.filled.Home
|
import androidx.compose.material.icons.filled.Home
|
||||||
|
import androidx.compose.material.icons.filled.Movie
|
||||||
import androidx.compose.material.icons.filled.Settings
|
import androidx.compose.material.icons.filled.Settings
|
||||||
import androidx.compose.material.icons.filled.Sync
|
import androidx.compose.material.icons.filled.Sync
|
||||||
import androidx.compose.material.icons.filled.Tv
|
import androidx.compose.material.icons.filled.Tv
|
||||||
|
|
@ -29,6 +30,7 @@ import androidx.navigation.compose.currentBackStackEntryAsState
|
||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation.compose.rememberNavController
|
||||||
import com.bingestats.app.ui.screens.AnalyticsScreen
|
import com.bingestats.app.ui.screens.AnalyticsScreen
|
||||||
import com.bingestats.app.ui.screens.DashboardScreen
|
import com.bingestats.app.ui.screens.DashboardScreen
|
||||||
|
import com.bingestats.app.ui.screens.MoviesScreen
|
||||||
import com.bingestats.app.ui.screens.ProviderSyncScreen
|
import com.bingestats.app.ui.screens.ProviderSyncScreen
|
||||||
import com.bingestats.app.ui.screens.SeriesStatsScreen
|
import com.bingestats.app.ui.screens.SeriesStatsScreen
|
||||||
import com.bingestats.app.ui.screens.SettingsScreen
|
import com.bingestats.app.ui.screens.SettingsScreen
|
||||||
|
|
@ -42,6 +44,7 @@ import com.bingestats.app.ui.viewmodel.MainViewModel
|
||||||
sealed class Screen(val route: String, val title: String, val icon: ImageVector) {
|
sealed class Screen(val route: String, val title: String, val icon: ImageVector) {
|
||||||
object Dashboard : Screen("dashboard", "Home", Icons.Default.Home)
|
object Dashboard : Screen("dashboard", "Home", Icons.Default.Home)
|
||||||
object Series : Screen("series", "Serien", Icons.Default.Tv)
|
object Series : Screen("series", "Serien", Icons.Default.Tv)
|
||||||
|
object Movies : Screen("movies", "Filme", Icons.Default.Movie)
|
||||||
object Analytics : Screen("analytics", "Analytics", Icons.Default.Analytics)
|
object Analytics : Screen("analytics", "Analytics", Icons.Default.Analytics)
|
||||||
object Sync : Screen("sync", "Sync", Icons.Default.Sync)
|
object Sync : Screen("sync", "Sync", Icons.Default.Sync)
|
||||||
object Settings : Screen("settings", "Settings", Icons.Default.Settings)
|
object Settings : Screen("settings", "Settings", Icons.Default.Settings)
|
||||||
|
|
@ -52,7 +55,7 @@ class MainActivity : ComponentActivity() {
|
||||||
private val viewModel: MainViewModel by viewModels()
|
private val viewModel: MainViewModel by viewModels()
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate()
|
super.onCreate(savedInstanceState)
|
||||||
setContent {
|
setContent {
|
||||||
BingeStatsTheme {
|
BingeStatsTheme {
|
||||||
MainAppStructure(viewModel)
|
MainAppStructure(viewModel)
|
||||||
|
|
@ -70,6 +73,7 @@ fun MainAppStructure(viewModel: MainViewModel) {
|
||||||
val screens = listOf(
|
val screens = listOf(
|
||||||
Screen.Dashboard,
|
Screen.Dashboard,
|
||||||
Screen.Series,
|
Screen.Series,
|
||||||
|
Screen.Movies,
|
||||||
Screen.Analytics,
|
Screen.Analytics,
|
||||||
Screen.Sync,
|
Screen.Sync,
|
||||||
Screen.Settings
|
Screen.Settings
|
||||||
|
|
@ -123,6 +127,9 @@ fun MainAppStructure(viewModel: MainViewModel) {
|
||||||
composable(Screen.Series.route) {
|
composable(Screen.Series.route) {
|
||||||
SeriesStatsScreen(viewModel = viewModel)
|
SeriesStatsScreen(viewModel = viewModel)
|
||||||
}
|
}
|
||||||
|
composable(Screen.Movies.route) {
|
||||||
|
MoviesScreen(viewModel = viewModel)
|
||||||
|
}
|
||||||
composable(Screen.Analytics.route) {
|
composable(Screen.Analytics.route) {
|
||||||
AnalyticsScreen(viewModel = viewModel)
|
AnalyticsScreen(viewModel = viewModel)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,15 @@ data class ShowAggregation(
|
||||||
val posterPath: String?
|
val posterPath: String?
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data class MovieAggregation(
|
||||||
|
val showTitle: String,
|
||||||
|
val provider: String,
|
||||||
|
val watchCount: Int,
|
||||||
|
val totalMinutes: Int,
|
||||||
|
val lastWatchedDate: Long,
|
||||||
|
val posterPath: String?
|
||||||
|
)
|
||||||
|
|
||||||
data class MonthlyAggregation(
|
data class MonthlyAggregation(
|
||||||
val yearMonth: String,
|
val yearMonth: String,
|
||||||
val totalMinutes: Int
|
val totalMinutes: Int
|
||||||
|
|
@ -48,18 +57,35 @@ interface ViewingDao {
|
||||||
@Query("SELECT COUNT(*) FROM viewing_items")
|
@Query("SELECT COUNT(*) FROM viewing_items")
|
||||||
fun getTotalItemsCount(): Flow<Int>
|
fun getTotalItemsCount(): Flow<Int>
|
||||||
|
|
||||||
@Query("SELECT COUNT(DISTINCT showTitle) FROM viewing_items")
|
@Query("SELECT COUNT(DISTINCT showTitle) FROM viewing_items WHERE contentType != 'MOVIE'")
|
||||||
fun getUniqueShowsCount(): Flow<Int>
|
fun getUniqueShowsCount(): Flow<Int>
|
||||||
|
|
||||||
|
@Query("SELECT COUNT(*) FROM viewing_items WHERE contentType = 'MOVIE'")
|
||||||
|
fun getMoviesCount(): Flow<Int>
|
||||||
|
|
||||||
|
@Query("SELECT COUNT(*) FROM viewing_items WHERE contentType != 'MOVIE'")
|
||||||
|
fun getSeriesEpisodesCount(): Flow<Int>
|
||||||
|
|
||||||
@Query("""
|
@Query("""
|
||||||
SELECT showTitle, provider, COUNT(*) as episodeCount, SUM(durationMinutes) as totalMinutes, MAX(watchDate) as lastWatchedDate, MAX(posterPath) as posterPath
|
SELECT showTitle, provider, COUNT(*) as episodeCount, SUM(durationMinutes) as totalMinutes, MAX(watchDate) as lastWatchedDate, MAX(posterPath) as posterPath
|
||||||
FROM viewing_items
|
FROM viewing_items
|
||||||
|
WHERE contentType != 'MOVIE'
|
||||||
GROUP BY showTitle
|
GROUP BY showTitle
|
||||||
ORDER BY totalMinutes DESC
|
ORDER BY totalMinutes DESC
|
||||||
LIMIT :limit
|
LIMIT :limit
|
||||||
""")
|
""")
|
||||||
fun getTopShows(limit: Int = 20): Flow<List<ShowAggregation>>
|
fun getTopShows(limit: Int = 20): Flow<List<ShowAggregation>>
|
||||||
|
|
||||||
|
@Query("""
|
||||||
|
SELECT showTitle, provider, COUNT(*) as watchCount, SUM(durationMinutes) as totalMinutes, MAX(watchDate) as lastWatchedDate, MAX(posterPath) as posterPath
|
||||||
|
FROM viewing_items
|
||||||
|
WHERE contentType = 'MOVIE'
|
||||||
|
GROUP BY showTitle
|
||||||
|
ORDER BY totalMinutes DESC
|
||||||
|
LIMIT :limit
|
||||||
|
""")
|
||||||
|
fun getTopMovies(limit: Int = 20): Flow<List<MovieAggregation>>
|
||||||
|
|
||||||
@Query("""
|
@Query("""
|
||||||
SELECT strftime('%Y-%m', watchDate / 1000, 'unixepoch') as yearMonth, SUM(durationMinutes) as totalMinutes
|
SELECT strftime('%Y-%m', watchDate / 1000, 'unixepoch') as yearMonth, SUM(durationMinutes) as totalMinutes
|
||||||
FROM viewing_items
|
FROM viewing_items
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,18 @@ data class ShowSummary(
|
||||||
get() = totalMinutes / 60f
|
get() = totalMinutes / 60f
|
||||||
}
|
}
|
||||||
|
|
||||||
|
data class MovieSummary(
|
||||||
|
val showTitle: String,
|
||||||
|
val provider: StreamingProvider,
|
||||||
|
val watchCount: Int,
|
||||||
|
val totalMinutes: Int,
|
||||||
|
val lastWatchedDate: Long,
|
||||||
|
val posterPath: String? = null
|
||||||
|
) {
|
||||||
|
val totalHours: Float
|
||||||
|
get() = totalMinutes / 60f
|
||||||
|
}
|
||||||
|
|
||||||
data class MonthlyStat(
|
data class MonthlyStat(
|
||||||
val yearMonth: String, // e.g. "2024-05"
|
val yearMonth: String, // e.g. "2024-05"
|
||||||
val monthLabel: String, // e.g. "Mai 2024"
|
val monthLabel: String, // e.g. "Mai 2024"
|
||||||
|
|
@ -39,6 +51,7 @@ data class WatchStats(
|
||||||
val moviesCount: Int = 0,
|
val moviesCount: Int = 0,
|
||||||
val uniqueShowsCount: Int = 0,
|
val uniqueShowsCount: Int = 0,
|
||||||
val topShows: List<ShowSummary> = emptyList(),
|
val topShows: List<ShowSummary> = emptyList(),
|
||||||
|
val topMovies: List<MovieSummary> = emptyList(),
|
||||||
val monthlyStats: List<MonthlyStat> = emptyList(),
|
val monthlyStats: List<MonthlyStat> = emptyList(),
|
||||||
val dayOfWeekStats: List<DayOfWeekStat> = emptyList(),
|
val dayOfWeekStats: List<DayOfWeekStat> = emptyList(),
|
||||||
val providerStats: List<ProviderStat> = emptyList()
|
val providerStats: List<ProviderStat> = emptyList()
|
||||||
|
|
|
||||||
|
|
@ -55,34 +55,80 @@ object NetflixParser {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse Netflix JSON API viewing activity payload (intercepted during WebView browsing).
|
* Parse JSON viewing activity payload (intercepted during WebView browsing).
|
||||||
|
* Accepts three shapes:
|
||||||
|
* 1. The Netflix Shakti API object: { "viewedItems": [ ... ] }
|
||||||
|
* 2. A flat JSON array: [ { ... }, { ... } ]
|
||||||
|
* 3. Any object exposing a "viewedItems" or "items" array.
|
||||||
|
* Each entry may use Netflix fields (title/seriesTitle/episodeTitle/date/duration)
|
||||||
|
* or the simpler Prime-style fields (title/episodeTitle/time/duration).
|
||||||
|
*
|
||||||
|
* @param provider the provider id stamped onto every parsed item.
|
||||||
*/
|
*/
|
||||||
fun parseJsonApiPayload(jsonText: String, profileName: String = "Main Profile"): List<ViewingItem> {
|
fun parseJsonApiPayload(
|
||||||
|
jsonText: String,
|
||||||
|
profileName: String = "Main Profile",
|
||||||
|
provider: String = StreamingProvider.NETFLIX.id
|
||||||
|
): List<ViewingItem> {
|
||||||
val items = mutableListOf<ViewingItem>()
|
val items = mutableListOf<ViewingItem>()
|
||||||
try {
|
try {
|
||||||
val gson = Gson()
|
val gson = Gson()
|
||||||
val jsonObject = gson.fromJson(jsonText, JsonObject::class.java)
|
val jsonElement = gson.fromJson(jsonText, com.google.gson.JsonElement::class.java) ?: return items
|
||||||
val viewingData = jsonObject.getAsJsonArray("viewedItems") ?: return items
|
|
||||||
|
val viewingData: JsonArray = when {
|
||||||
|
jsonElement.isJsonArray -> jsonElement.asJsonArray
|
||||||
|
jsonElement.isJsonObject -> {
|
||||||
|
val obj = jsonElement.asJsonObject
|
||||||
|
obj.getAsJsonArray("viewedItems")
|
||||||
|
?: obj.getAsJsonArray("items")
|
||||||
|
?: obj.getAsJsonArray("history")
|
||||||
|
?: return items
|
||||||
|
}
|
||||||
|
else -> return items
|
||||||
|
}
|
||||||
|
|
||||||
for (i in 0 until viewingData.size()) {
|
for (i in 0 until viewingData.size()) {
|
||||||
val item = viewingData[i].asJsonObject
|
val item = viewingData[i].takeIf { it.isJsonObject }?.asJsonObject ?: continue
|
||||||
val title = item.get("title")?.asString ?: item.get("seriesTitle")?.asString ?: continue
|
|
||||||
val dateEpoch = item.get("date")?.asLong ?: System.currentTimeMillis()
|
// Series episodes carry seriesTitle + episodeTitle; movies carry only title.
|
||||||
|
val seriesTitle = item.get("seriesTitle")?.takeIf { !it.isJsonNull }?.asString
|
||||||
val parsedTitle = TitleParser.parseNetflixTitle(title)
|
val episodeTitle = item.get("episodeTitle")?.takeIf { !it.isJsonNull }?.asString
|
||||||
val (timestamp, dateFormatted) = TitleParser.parseDateToEpoch(dateEpoch.toString())
|
val title = item.get("title")?.takeIf { !it.isJsonNull }?.asString
|
||||||
|
?: seriesTitle
|
||||||
|
?: continue
|
||||||
|
|
||||||
|
// Build a raw title string the TitleParser understands.
|
||||||
|
val rawTitle = when {
|
||||||
|
seriesTitle != null && episodeTitle != null -> "$seriesTitle: $episodeTitle"
|
||||||
|
else -> title
|
||||||
|
}
|
||||||
|
|
||||||
|
val parsedTitle = TitleParser.parseNetflixTitle(rawTitle)
|
||||||
|
|
||||||
|
// Date: "date" (ms epoch) for Netflix, "time" (ISO string or epoch) for Prime.
|
||||||
|
val dateEpoch = when {
|
||||||
|
item.has("date") && !item.get("date").isJsonNull -> item.get("date").asLong
|
||||||
|
item.has("time") && !item.get("time").isJsonNull -> parseTimeToEpoch(item.get("time").asString)
|
||||||
|
else -> System.currentTimeMillis()
|
||||||
|
}
|
||||||
|
val (_, dateFormatted) = TitleParser.parseDateToEpoch(dateEpoch.toString())
|
||||||
|
|
||||||
|
// Duration in minutes: Netflix/Prime provide it in ms.
|
||||||
|
val durationMs = item.get("duration")?.takeIf { !it.isJsonNull }?.asLong
|
||||||
|
val durationMinutes = (durationMs?.let { (it / 60000L).toInt() }?.takeIf { d -> d > 0 })
|
||||||
|
?: if (parsedTitle.contentType == com.bingestats.app.data.model.ContentType.SERIES) 45 else 105
|
||||||
|
|
||||||
items.add(
|
items.add(
|
||||||
ViewingItem(
|
ViewingItem(
|
||||||
provider = StreamingProvider.NETFLIX.id,
|
provider = provider,
|
||||||
rawTitle = title,
|
rawTitle = rawTitle,
|
||||||
showTitle = parsedTitle.showTitle,
|
showTitle = parsedTitle.showTitle,
|
||||||
seasonTitle = parsedTitle.seasonTitle,
|
seasonTitle = parsedTitle.seasonTitle,
|
||||||
episodeTitle = parsedTitle.episodeTitle,
|
episodeTitle = parsedTitle.episodeTitle,
|
||||||
contentType = parsedTitle.contentType,
|
contentType = parsedTitle.contentType,
|
||||||
watchDate = dateEpoch,
|
watchDate = dateEpoch,
|
||||||
dateFormatted = dateFormatted,
|
dateFormatted = dateFormatted,
|
||||||
durationMinutes = if (parsedTitle.contentType == com.bingestats.app.data.model.ContentType.SERIES) 45 else 105,
|
durationMinutes = durationMinutes,
|
||||||
profileName = profileName
|
profileName = profileName
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
@ -93,6 +139,38 @@ object NetflixParser {
|
||||||
return items
|
return items
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Best-effort parse of a time value to epoch millis.
|
||||||
|
* Accepts a numeric epoch (seconds or millis) or an ISO-8601 datetime string.
|
||||||
|
*/
|
||||||
|
private fun parseTimeToEpoch(timeStr: String): Long {
|
||||||
|
val trimmed = timeStr.trim()
|
||||||
|
// Pure number → epoch seconds (typical) or millis.
|
||||||
|
return trimmed.toLongOrNull()?.let { num ->
|
||||||
|
// Heuristic: values < year 3000 in seconds (~325 billion) treat as seconds.
|
||||||
|
if (num < 325_000_000_000L) num * 1000L else num
|
||||||
|
} ?: run {
|
||||||
|
// ISO-8601 string, e.g. "2025-07-15 21:04"
|
||||||
|
val formats = listOf(
|
||||||
|
"yyyy-MM-dd'T'HH:mm:ss",
|
||||||
|
"yyyy-MM-dd'T'HH:mm:ssXXX",
|
||||||
|
"yyyy-MM-dd HH:mm:ss",
|
||||||
|
"yyyy-MM-dd HH:mm",
|
||||||
|
"yyyy-MM-dd"
|
||||||
|
)
|
||||||
|
for (format in formats) {
|
||||||
|
try {
|
||||||
|
val sdf = java.text.SimpleDateFormat(format, java.util.Locale.getDefault())
|
||||||
|
sdf.timeZone = java.util.TimeZone.getDefault()
|
||||||
|
return sdf.parse(trimmed)?.time ?: continue
|
||||||
|
} catch (_: Exception) {
|
||||||
|
// try next format
|
||||||
|
}
|
||||||
|
}
|
||||||
|
System.currentTimeMillis()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun parseCsvLine(line: String): List<String> {
|
private fun parseCsvLine(line: String): List<String> {
|
||||||
val result = mutableListOf<String>()
|
val result = mutableListOf<String>()
|
||||||
var inQuotes = false
|
var inQuotes = false
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,123 @@
|
||||||
|
package com.bingestats.app.data.parser
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provider-specific JavaScript snippets injected into the in-app WebView to extract
|
||||||
|
* viewing history as a clean JSON string. The result is fed to
|
||||||
|
* [NetflixParser.parseJsonApiPayload].
|
||||||
|
*
|
||||||
|
* Why JS extraction instead of parsing the rendered DOM:
|
||||||
|
* - Netflix's page is a React SPA with hashed, unstable CSS class names.
|
||||||
|
* The reliable source is the internal Shakti API, reachable in-page because the
|
||||||
|
* WebView session already holds the NetflixId cookies.
|
||||||
|
* - Prime Video embeds history as inline JSON in <script type="text/template"> tags
|
||||||
|
* and lazy-loads more via fetch.
|
||||||
|
*/
|
||||||
|
object WebSyncScripts {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Netflix: read BUILD_IDENTIFIER + authURL from the page's global context, then
|
||||||
|
* page through /api/shakti/<build>/viewingactivity collecting viewedItems.
|
||||||
|
* Returns a JSON array string (or "[]" on failure).
|
||||||
|
*/
|
||||||
|
val netflix: String = """
|
||||||
|
(function() {
|
||||||
|
try {
|
||||||
|
var ctx = window.netflix && window.netflix.reactContext;
|
||||||
|
if (!ctx) return JSON.stringify([]);
|
||||||
|
var serverDefs = ctx.models && ctx.models.serverDefs && ctx.models.serverDefs.data;
|
||||||
|
if (!serverDefs || !serverDefs.BUILD_IDENTIFIER) return JSON.stringify([]);
|
||||||
|
var buildId = serverDefs.BUILD_IDENTIFIER;
|
||||||
|
var authURL = "";
|
||||||
|
try {
|
||||||
|
authURL = ctx.models.memberContext.data.userInfo.data.authURL
|
||||||
|
|| ctx.models.userInfo.data.authURL || "";
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
|
var collected = [];
|
||||||
|
var pageSize = 100;
|
||||||
|
var pg = 0;
|
||||||
|
var total = -1;
|
||||||
|
|
||||||
|
// Synchronous loop over pages. fetch() is async, so we chain via a
|
||||||
|
// recursive helper and resolve the outer Promise.
|
||||||
|
return new Promise(function(resolve) {
|
||||||
|
function loadPage(n) {
|
||||||
|
var url = '/api/shakti/' + buildId + '/viewingactivity?pg=' + n + '&pgsize=' + pageSize;
|
||||||
|
if (authURL) url += '&authURL=' + encodeURIComponent(authURL);
|
||||||
|
fetch(url, { credentials: 'include' })
|
||||||
|
.then(function(r) { return r.ok ? r.json() : null; })
|
||||||
|
.then(function(data) {
|
||||||
|
if (!data) { resolve(JSON.stringify(collected)); return; }
|
||||||
|
var items = data.viewedItems || [];
|
||||||
|
collected = collected.concat(items);
|
||||||
|
if (total < 0) total = data.vhSize || 0;
|
||||||
|
pg++;
|
||||||
|
// Stop when a page is empty or we've read everything.
|
||||||
|
if (items.length === 0 || (total > 0 && collected.length >= total) || pg > 200) {
|
||||||
|
resolve(JSON.stringify(collected));
|
||||||
|
} else {
|
||||||
|
loadPage(pg);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function() { resolve(JSON.stringify(collected)); });
|
||||||
|
}
|
||||||
|
loadPage(0);
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
return JSON.stringify([]);
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prime Video: parse the inline JSON embedded in <script type="text/template"> tags.
|
||||||
|
* The structure is props -> widgets[] -> content.content.titles[] -> date sections,
|
||||||
|
* each section has .titles[] with {title:{text}, time, children:[...] }.
|
||||||
|
* Returns a JSON array string of {title, episodeTitle, time, duration}.
|
||||||
|
*/
|
||||||
|
val prime: String = """
|
||||||
|
(function() {
|
||||||
|
try {
|
||||||
|
var results = [];
|
||||||
|
var scripts = document.querySelectorAll('script[type="text/template"]');
|
||||||
|
for (var i = 0; i < scripts.length; i++) {
|
||||||
|
var raw = scripts[i].textContent.trim();
|
||||||
|
if (!raw || raw.charAt(0) !== '{') continue;
|
||||||
|
var obj;
|
||||||
|
try { obj = JSON.parse(raw); } catch (e) { continue; }
|
||||||
|
var widgets = (obj && obj.props) ? obj.props : [];
|
||||||
|
if (!Array.isArray(widgets)) widgets = [widgets];
|
||||||
|
widgets.forEach(function(w) {
|
||||||
|
try {
|
||||||
|
var titles = w && w.content && w.content.content && w.content.content.titles;
|
||||||
|
if (!Array.isArray(titles)) return;
|
||||||
|
titles.forEach(function(section) {
|
||||||
|
var items = section.titles || [];
|
||||||
|
items.forEach(function(t) {
|
||||||
|
if (!t || !t.title || !t.title.text) return;
|
||||||
|
results.push({
|
||||||
|
title: t.title.text,
|
||||||
|
time: t.time || null,
|
||||||
|
duration: t.duration || null
|
||||||
|
});
|
||||||
|
(t.children || []).forEach(function(c) {
|
||||||
|
if (!c || !c.title || !c.title.text) return;
|
||||||
|
results.push({
|
||||||
|
seriesTitle: t.title.text,
|
||||||
|
episodeTitle: c.title.text,
|
||||||
|
time: c.time || t.time || null,
|
||||||
|
duration: c.duration || null
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (e) {}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return JSON.stringify(results);
|
||||||
|
} catch (e) {
|
||||||
|
return JSON.stringify([]);
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
""".trimIndent()
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,12 @@
|
||||||
package com.bingestats.app.data.repository
|
package com.bingestats.app.data.repository
|
||||||
|
|
||||||
|
import com.bingestats.app.data.db.MovieAggregation
|
||||||
|
import com.bingestats.app.data.db.MonthlyAggregation
|
||||||
|
import com.bingestats.app.data.db.ProviderAggregation
|
||||||
|
import com.bingestats.app.data.db.ShowAggregation
|
||||||
import com.bingestats.app.data.db.ViewingDatabase
|
import com.bingestats.app.data.db.ViewingDatabase
|
||||||
import com.bingestats.app.data.model.DayOfWeekStat
|
import com.bingestats.app.data.model.DayOfWeekStat
|
||||||
|
import com.bingestats.app.data.model.MovieSummary
|
||||||
import com.bingestats.app.data.model.MonthlyStat
|
import com.bingestats.app.data.model.MonthlyStat
|
||||||
import com.bingestats.app.data.model.ProviderStat
|
import com.bingestats.app.data.model.ProviderStat
|
||||||
import com.bingestats.app.data.model.ShowSummary
|
import com.bingestats.app.data.model.ShowSummary
|
||||||
|
|
@ -30,10 +35,26 @@ class StatsRepository(private val db: ViewingDatabase) {
|
||||||
viewingDao.getTotalWatchTimeMinutes(),
|
viewingDao.getTotalWatchTimeMinutes(),
|
||||||
viewingDao.getTotalItemsCount(),
|
viewingDao.getTotalItemsCount(),
|
||||||
viewingDao.getUniqueShowsCount(),
|
viewingDao.getUniqueShowsCount(),
|
||||||
|
viewingDao.getMoviesCount(),
|
||||||
|
viewingDao.getSeriesEpisodesCount(),
|
||||||
viewingDao.getTopShows(20),
|
viewingDao.getTopShows(20),
|
||||||
|
viewingDao.getTopMovies(20),
|
||||||
viewingDao.getMonthlyStats(),
|
viewingDao.getMonthlyStats(),
|
||||||
viewingDao.getProviderStats()
|
viewingDao.getProviderStats()
|
||||||
) { totalMinutes, itemCount, uniqueShows, topShowsAgg, monthlyAgg, providerAgg ->
|
) { values ->
|
||||||
|
val totalMinutes = values[0] as Long?
|
||||||
|
val itemCount = values[1] as Int
|
||||||
|
val uniqueShows = values[2] as Int
|
||||||
|
val moviesCount = values[3] as Int
|
||||||
|
val seriesEpisodesCount = values[4] as Int
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
val topShowsAgg = values[5] as List<ShowAggregation>
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
val topMoviesAgg = values[6] as List<MovieAggregation>
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
val monthlyAgg = values[7] as List<MonthlyAggregation>
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
val providerAgg = values[8] as List<ProviderAggregation>
|
||||||
|
|
||||||
val topShows = topShowsAgg.map { agg ->
|
val topShows = topShowsAgg.map { agg ->
|
||||||
ShowSummary(
|
ShowSummary(
|
||||||
|
|
@ -46,6 +67,17 @@ class StatsRepository(private val db: ViewingDatabase) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val topMovies = topMoviesAgg.map { agg ->
|
||||||
|
MovieSummary(
|
||||||
|
showTitle = agg.showTitle,
|
||||||
|
provider = StreamingProvider.fromId(agg.provider),
|
||||||
|
watchCount = agg.watchCount,
|
||||||
|
totalMinutes = agg.totalMinutes,
|
||||||
|
lastWatchedDate = agg.lastWatchedDate,
|
||||||
|
posterPath = agg.posterPath
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
val monthlyStats = monthlyAgg.map { agg ->
|
val monthlyStats = monthlyAgg.map { agg ->
|
||||||
MonthlyStat(
|
MonthlyStat(
|
||||||
yearMonth = agg.yearMonth,
|
yearMonth = agg.yearMonth,
|
||||||
|
|
@ -68,10 +100,11 @@ class StatsRepository(private val db: ViewingDatabase) {
|
||||||
WatchStats(
|
WatchStats(
|
||||||
totalWatchTimeMinutes = totalMinutes ?: 0L,
|
totalWatchTimeMinutes = totalMinutes ?: 0L,
|
||||||
totalItemsCount = itemCount,
|
totalItemsCount = itemCount,
|
||||||
seriesEpisodesCount = topShows.sumOf { it.episodeCount },
|
seriesEpisodesCount = seriesEpisodesCount,
|
||||||
moviesCount = (itemCount - topShows.sumOf { it.episodeCount }).coerceAtLeast(0),
|
moviesCount = moviesCount,
|
||||||
uniqueShowsCount = uniqueShows,
|
uniqueShowsCount = uniqueShows,
|
||||||
topShows = topShows,
|
topShows = topShows,
|
||||||
|
topMovies = topMovies,
|
||||||
monthlyStats = monthlyStats,
|
monthlyStats = monthlyStats,
|
||||||
providerStats = providerStats
|
providerStats = providerStats
|
||||||
)
|
)
|
||||||
|
|
|
||||||
172
app/src/main/java/com/bingestats/app/ui/screens/MoviesScreen.kt
Normal file
172
app/src/main/java/com/bingestats/app/ui/screens/MoviesScreen.kt
Normal file
|
|
@ -0,0 +1,172 @@
|
||||||
|
package com.bingestats.app.ui.screens
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
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.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Movie
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import com.bingestats.app.data.model.MovieSummary
|
||||||
|
import com.bingestats.app.ui.components.ProviderBadge
|
||||||
|
import com.bingestats.app.ui.theme.CardBackgroundDark
|
||||||
|
import com.bingestats.app.ui.theme.GlassCardBorder
|
||||||
|
import com.bingestats.app.ui.theme.NetflixRed
|
||||||
|
import com.bingestats.app.ui.theme.ObsidianBlack
|
||||||
|
import com.bingestats.app.ui.theme.TextMuted
|
||||||
|
import com.bingestats.app.ui.theme.TextSecondary
|
||||||
|
import com.bingestats.app.ui.viewmodel.MainViewModel
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MoviesScreen(viewModel: MainViewModel) {
|
||||||
|
val stats by viewModel.watchStats.collectAsState()
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(ObsidianBlack)
|
||||||
|
.padding(16.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Top Filme Ranking",
|
||||||
|
fontSize = 24.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = Color.White
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Deine meistgesehenen Filme nach Gesamtzeit",
|
||||||
|
fontSize = 13.sp,
|
||||||
|
color = TextMuted
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
if (stats.topMovies.isEmpty()) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Noch keine Filme erfasst.\nImportiere deinen Verlauf unter Sync.",
|
||||||
|
color = TextMuted,
|
||||||
|
fontSize = 14.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
LazyColumn(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||||
|
) {
|
||||||
|
itemsIndexed(stats.topMovies) { index, movie ->
|
||||||
|
MovieRankItem(rank = index + 1, movie = movie)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MovieRankItem(
|
||||||
|
rank: Int,
|
||||||
|
movie: MovieSummary
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.border(1.dp, GlassCardBorder, RoundedCornerShape(12.dp)),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = CardBackgroundDark),
|
||||||
|
shape = RoundedCornerShape(12.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(14.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
// Rank Number
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.width(36.dp)
|
||||||
|
.height(36.dp)
|
||||||
|
.background(
|
||||||
|
if (rank <= 3) NetflixRed.copy(alpha = 0.2f) else Color.Transparent,
|
||||||
|
RoundedCornerShape(8.dp)
|
||||||
|
),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "#$rank",
|
||||||
|
fontWeight = FontWeight.Black,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
color = if (rank <= 3) NetflixRed else TextMuted
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
|
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = movie.showTitle,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = Color.White
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
ProviderBadge(provider = movie.provider)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Movie,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = TextSecondary,
|
||||||
|
modifier = Modifier.width(16.dp).height(16.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
Text(
|
||||||
|
text = if (movie.watchCount == 1) "1x gesehen" else "${movie.watchCount}x gesehen",
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = TextSecondary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(horizontalAlignment = Alignment.End) {
|
||||||
|
Text(
|
||||||
|
text = "${movie.totalMinutes / 60}h ${movie.totalMinutes % 60}m",
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = NetflixRed
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "${movie.totalMinutes} Minuten",
|
||||||
|
fontSize = 11.sp,
|
||||||
|
color = TextMuted
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,7 @@ import android.webkit.CookieManager
|
||||||
import android.webkit.WebResourceRequest
|
import android.webkit.WebResourceRequest
|
||||||
import android.webkit.WebView
|
import android.webkit.WebView
|
||||||
import android.webkit.WebViewClient
|
import android.webkit.WebViewClient
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
|
@ -26,7 +27,6 @@ import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.CloudUpload
|
import androidx.compose.material.icons.filled.CloudUpload
|
||||||
import androidx.compose.material.icons.filled.Language
|
import androidx.compose.material.icons.filled.Language
|
||||||
import androidx.compose.material.icons.filled.Lock
|
import androidx.compose.material.icons.filled.Lock
|
||||||
import androidx.compose.material.icons.filled.Refresh
|
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
|
|
@ -39,6 +39,7 @@ import androidx.compose.material3.TabRowDefaults
|
||||||
import androidx.compose.material3.TabRowDefaults.tabIndicatorOffset
|
import androidx.compose.material3.TabRowDefaults.tabIndicatorOffset
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
|
@ -54,6 +55,7 @@ import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.compose.ui.viewinterop.AndroidView
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
import com.bingestats.app.data.model.StreamingProvider
|
import com.bingestats.app.data.model.StreamingProvider
|
||||||
|
import com.bingestats.app.data.parser.WebSyncScripts
|
||||||
import com.bingestats.app.ui.components.ProviderBadge
|
import com.bingestats.app.ui.components.ProviderBadge
|
||||||
import com.bingestats.app.ui.theme.CardBackgroundDark
|
import com.bingestats.app.ui.theme.CardBackgroundDark
|
||||||
import com.bingestats.app.ui.theme.GlassCardBorder
|
import com.bingestats.app.ui.theme.GlassCardBorder
|
||||||
|
|
@ -232,9 +234,33 @@ fun WebViewSyncSection(
|
||||||
provider: StreamingProvider,
|
provider: StreamingProvider,
|
||||||
onExtractHistory: (String) -> Unit
|
onExtractHistory: (String) -> Unit
|
||||||
) {
|
) {
|
||||||
var webViewRef by remember { mutableStateOf<WebView?>(null) }
|
// webSyncSupported == false -> show a graceful hint instead of a broken webview.
|
||||||
var currentUrl by remember { mutableStateOf(provider.defaultWebUrl) }
|
val webSyncSupported = provider == StreamingProvider.NETFLIX || provider == StreamingProvider.AMAZON_PRIME
|
||||||
var isLoadingWeb by remember { mutableStateOf(false) }
|
|
||||||
|
var currentUrl by remember(provider) { mutableStateOf(provider.viewingHistoryUrl) }
|
||||||
|
var isLoadingWeb by remember { mutableStateOf(true) }
|
||||||
|
var isExtracting by remember { mutableStateOf(false) }
|
||||||
|
var statusMessage by remember { mutableStateOf<String?>(null) }
|
||||||
|
val webViewRef = remember { mutableStateOf<WebView?>(null) }
|
||||||
|
|
||||||
|
// Clean up the WebView when the composable leaves the composition. Without this the
|
||||||
|
// WebView can keep running JS/holding the screen, which previously blocked navigation.
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
onDispose {
|
||||||
|
webViewRef.value?.apply {
|
||||||
|
stopLoading()
|
||||||
|
removeAllViews()
|
||||||
|
destroy()
|
||||||
|
}
|
||||||
|
webViewRef.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Route the system back button: if the WebView has history, go back inside it;
|
||||||
|
// otherwise let the default (NavController pop) take over.
|
||||||
|
BackHandler(enabled = webViewRef.value?.canGoBack() == true) {
|
||||||
|
webViewRef.value?.goBack()
|
||||||
|
}
|
||||||
|
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
Card(
|
Card(
|
||||||
|
|
@ -243,52 +269,89 @@ fun WebViewSyncSection(
|
||||||
.border(1.dp, GlassCardBorder, RoundedCornerShape(12.dp)),
|
.border(1.dp, GlassCardBorder, RoundedCornerShape(12.dp)),
|
||||||
colors = CardDefaults.cardColors(containerColor = CardBackgroundDark)
|
colors = CardDefaults.cardColors(containerColor = CardBackgroundDark)
|
||||||
) {
|
) {
|
||||||
Row(
|
Column(modifier = Modifier.padding(12.dp)) {
|
||||||
modifier = Modifier
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
.fillMaxWidth()
|
Icon(Icons.Default.Lock, contentDescription = null, tint = TextMuted, modifier = Modifier.padding(end = 6.dp))
|
||||||
.padding(8.dp),
|
Text(
|
||||||
verticalAlignment = Alignment.CenterVertically
|
text = currentUrl,
|
||||||
) {
|
fontSize = 11.sp,
|
||||||
Icon(Icons.Default.Lock, contentDescription = null, tint = TextMuted, modifier = Modifier.padding(4.dp))
|
color = TextMuted,
|
||||||
Text(
|
modifier = Modifier.weight(1f),
|
||||||
text = currentUrl,
|
maxLines = 1
|
||||||
fontSize = 11.sp,
|
)
|
||||||
color = TextMuted,
|
Button(
|
||||||
modifier = Modifier.weight(1f),
|
enabled = webSyncSupported && !isExtracting,
|
||||||
maxLines = 1
|
onClick = {
|
||||||
)
|
val webview = webViewRef.value ?: return@Button
|
||||||
Button(
|
isExtracting = true
|
||||||
onClick = {
|
statusMessage = "Lese Verlauf aus…"
|
||||||
webViewRef?.evaluateJavascript(
|
runExtraction(webview, provider) { result ->
|
||||||
"(function() { return document.documentElement.outerHTML; })();"
|
isExtracting = false
|
||||||
) { html ->
|
statusMessage = null
|
||||||
if (!html.isNullOrBlank()) {
|
if (!result.isNullOrBlank() && result != "[]" && result != "null") {
|
||||||
onExtractHistory(html)
|
onExtractHistory(result)
|
||||||
|
} else {
|
||||||
|
statusMessage = "Keine Daten gefunden. Stelle sicher, dass du eingeloggt bist und sich der Verlauf geladen hat."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
},
|
colors = ButtonDefaults.buttonColors(
|
||||||
colors = ButtonDefaults.buttonColors(containerColor = provider.primaryColor)
|
containerColor = provider.primaryColor,
|
||||||
) {
|
disabledContainerColor = provider.primaryColor.copy(alpha = 0.4f)
|
||||||
Text("Verlauf Auslesen", fontSize = 12.sp, color = Color.White)
|
)
|
||||||
|
) {
|
||||||
|
Text("Verlauf Auslesen", fontSize = 12.sp, color = Color.White)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
statusMessage?.let { msg ->
|
||||||
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
|
Text(text = msg, fontSize = 11.sp, color = TextSecondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
if (!webSyncSupported) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(CardBackgroundDark, RoundedCornerShape(12.dp))
|
||||||
|
.border(1.dp, GlassCardBorder, RoundedCornerShape(12.dp)),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Web-Sync für ${provider.displayName} wird noch nicht unterstützt.\nBitte nutze den CSV-Import.",
|
||||||
|
color = TextMuted,
|
||||||
|
fontSize = 13.sp,
|
||||||
|
modifier = Modifier.padding(24.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
AndroidView(
|
AndroidView(
|
||||||
factory = { context ->
|
factory = { context ->
|
||||||
WebView(context).apply {
|
WebView(context).apply {
|
||||||
settings.javaScriptEnabled = true
|
settings.javaScriptEnabled = true
|
||||||
settings.domStorageEnabled = true
|
settings.domStorageEnabled = true
|
||||||
|
// Allow third-party cookies so login flows survive the redirect chain.
|
||||||
|
CookieManager.getInstance().setAcceptThirdPartyCookies(this, true)
|
||||||
settings.userAgentString = "Mozilla/5.0 (Linux; Android 10; Mobile) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36"
|
settings.userAgentString = "Mozilla/5.0 (Linux; Android 10; Mobile) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36"
|
||||||
|
|
||||||
webViewClient = object : WebViewClient() {
|
webViewClient = object : WebViewClient() {
|
||||||
override fun onPageFinished(view: WebView?, url: String?) {
|
override fun onPageFinished(view: WebView?, url: String?) {
|
||||||
super.onPageFinished(view, url)
|
super.onPageFinished(view, url)
|
||||||
currentUrl = url ?: ""
|
currentUrl = url ?: ""
|
||||||
isLoadingWeb = false
|
isLoadingWeb = false
|
||||||
|
// Install the JS<->Kotlin bridge: the extraction promise resolves
|
||||||
|
// into window.__bingeResult, which runExtraction() then polls.
|
||||||
|
view?.evaluateJavascript(
|
||||||
|
"window.__bingeResult = null; window.__bingeExtracted = function(v) { window.__bingeResult = (typeof v === 'string') ? v : JSON.stringify(v); };",
|
||||||
|
null
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean {
|
override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean {
|
||||||
|
|
@ -297,20 +360,17 @@ fun WebViewSyncSection(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
loadUrl(provider.viewingHistoryUrl)
|
loadUrl(provider.viewingHistoryUrl)
|
||||||
webViewRef = this
|
webViewRef.value = this
|
||||||
}
|
|
||||||
},
|
|
||||||
update = { webView ->
|
|
||||||
if (webView.url != provider.viewingHistoryUrl && !currentUrl.contains(provider.id)) {
|
|
||||||
webView.loadUrl(provider.viewingHistoryUrl)
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
// Removed the old aggressive reload: only react to provider changes, not every recomposition.
|
||||||
|
update = { _ -> },
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.border(1.dp, GlassCardBorder, RoundedCornerShape(8.dp))
|
.border(1.dp, GlassCardBorder, RoundedCornerShape(8.dp))
|
||||||
)
|
)
|
||||||
|
|
||||||
if (isLoadingWeb) {
|
if (isLoadingWeb || isExtracting) {
|
||||||
CircularProgressIndicator(
|
CircularProgressIndicator(
|
||||||
color = provider.primaryColor,
|
color = provider.primaryColor,
|
||||||
modifier = Modifier.align(Alignment.Center)
|
modifier = Modifier.align(Alignment.Center)
|
||||||
|
|
@ -320,6 +380,59 @@ fun WebViewSyncSection(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Injects the provider-specific extraction script into the WebView. The Netflix script
|
||||||
|
* returns a Promise, so we wrap evaluation in a `.then()` and read the resolved JSON.
|
||||||
|
*/
|
||||||
|
private fun runExtraction(
|
||||||
|
webView: WebView,
|
||||||
|
provider: StreamingProvider,
|
||||||
|
onResult: (String?) -> Unit
|
||||||
|
) {
|
||||||
|
val script = when (provider) {
|
||||||
|
StreamingProvider.NETFLIX -> WebSyncScripts.netflix
|
||||||
|
StreamingProvider.AMAZON_PRIME -> WebSyncScripts.prime
|
||||||
|
else -> { onResult(null); return }
|
||||||
|
}
|
||||||
|
|
||||||
|
val wrapped = """
|
||||||
|
(function() {
|
||||||
|
var r = (function() { $script })();
|
||||||
|
if (r && typeof r.then === 'function') {
|
||||||
|
r.then(function(v) { __bingeExtracted(v); }).catch(function() { __bingeExtracted('[]'); });
|
||||||
|
} else {
|
||||||
|
__bingeExtracted(r);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
// Bridge from JS back to Kotlin: store the result in a JS variable we then retrieve.
|
||||||
|
webView.evaluateJavascript(wrapped, null)
|
||||||
|
// Small delay so the async fetch (Netflix pagination) has time to complete before polling.
|
||||||
|
pollExtraction(webView, attempts = 0, onResult = onResult)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun pollExtraction(webView: WebView, attempts: Int, onResult: (String?) -> Unit) {
|
||||||
|
// The injected promise calls __bingeExtracted(v); we expose it via addJavascriptInterface-like
|
||||||
|
// retrieval: evaluate a getter. Cap attempts so we never spin forever.
|
||||||
|
val maxAttempts = 20
|
||||||
|
if (attempts > maxAttempts) {
|
||||||
|
onResult(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
webView.postDelayed({
|
||||||
|
webView.evaluateJavascript(
|
||||||
|
"(window.__bingeResult === undefined) ? null : window.__bingeResult;"
|
||||||
|
) { raw ->
|
||||||
|
if (raw == null || raw == "null" || raw == "undefined") {
|
||||||
|
pollExtraction(webView, attempts + 1, onResult)
|
||||||
|
} else {
|
||||||
|
onResult(raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 500)
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun CsvImportSection(
|
fun CsvImportSection(
|
||||||
provider: StreamingProvider,
|
provider: StreamingProvider,
|
||||||
|
|
|
||||||
|
|
@ -88,10 +88,21 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_uiState.value = UiState.Loading
|
_uiState.value = UiState.Loading
|
||||||
try {
|
try {
|
||||||
val items = if (jsonOrCsvText.trim().startsWith("{")) {
|
val trimmed = jsonOrCsvText.trim()
|
||||||
NetflixParser.parseJsonApiPayload(jsonOrCsvText)
|
// The WebView extraction returns a JSON array (or object); older flows may
|
||||||
|
// still pass CSV. Detect by the leading character.
|
||||||
|
val items = if (trimmed.startsWith("[") || trimmed.startsWith("{")) {
|
||||||
|
when (provider) {
|
||||||
|
StreamingProvider.NETFLIX,
|
||||||
|
StreamingProvider.AMAZON_PRIME ->
|
||||||
|
NetflixParser.parseJsonApiPayload(trimmed, provider = provider.id)
|
||||||
|
else -> NetflixParser.parseJsonApiPayload(trimmed, provider = provider.id)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
NetflixParser.parseCsv(jsonOrCsvText)
|
when (provider) {
|
||||||
|
StreamingProvider.AMAZON_PRIME -> PrimeVideoParser.parseCsv(trimmed)
|
||||||
|
else -> NetflixParser.parseCsv(trimmed)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (items.isEmpty()) {
|
if (items.isEmpty()) {
|
||||||
|
|
|
||||||
10
app/src/main/res/drawable/ic_launcher_background.xml
Normal file
10
app/src/main/res/drawable/ic_launcher_background.xml
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="108"
|
||||||
|
android:viewportHeight="108">
|
||||||
|
<path
|
||||||
|
android:fillColor="#141414"
|
||||||
|
android:pathData="M0,0h108v108h-108z" />
|
||||||
|
</vector>
|
||||||
11
app/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
11
app/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="108"
|
||||||
|
android:viewportHeight="108">
|
||||||
|
<!-- Rounded bar chart background mark in Netflix red -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#E50914"
|
||||||
|
android:pathData="M34,64h8v16h-8zM46,52h8v28h-8zM58,40h8v40h-8zM70,28h8v52h-8z" />
|
||||||
|
</vector>
|
||||||
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
Normal file
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@drawable/ic_launcher_background" />
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
</adaptive-icon>
|
||||||
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
Normal file
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@drawable/ic_launcher_background" />
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
</adaptive-icon>
|
||||||
3
app/src/main/res/values/strings.xml
Normal file
3
app/src/main/res/values/strings.xml
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">BingeStats</string>
|
||||||
|
</resources>
|
||||||
4
app/src/main/res/values/themes.xml
Normal file
4
app/src/main/res/values/themes.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
<resources>
|
||||||
|
<!-- Base theme for the Compose app: dark, no action bar -->
|
||||||
|
<style name="Theme.BingeStats" parent="android:Theme.Material.NoActionBar" />
|
||||||
|
</resources>
|
||||||
2
gradle/wrapper/gradle-wrapper.properties
vendored
2
gradle/wrapper/gradle-wrapper.properties
vendored
|
|
@ -1,6 +1,6 @@
|
||||||
distributionBase=GRADLE_USER_HOME
|
distributionBase=GRADLE_USER_HOME
|
||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
|
||||||
networkTimeout=10000
|
networkTimeout=10000
|
||||||
validateDistributionUrl=true
|
validateDistributionUrl=true
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
|
|
||||||
252
gradlew
vendored
Executable file
252
gradlew
vendored
Executable file
|
|
@ -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='"-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" "$@"
|
||||||
174
gradlew.bat
vendored
174
gradlew.bat
vendored
|
|
@ -1,80 +1,94 @@
|
||||||
@rem
|
@rem
|
||||||
@rem Copyright 2015 the original author or authors.
|
@rem Copyright 2015 the original author or authors.
|
||||||
@rem
|
@rem
|
||||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
@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 not use this file except in compliance with the License.
|
||||||
@rem You may obtain a copy of the License at
|
@rem You may obtain a copy of the License at
|
||||||
@rem
|
@rem
|
||||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||||
@rem
|
@rem
|
||||||
@rem Unless required by applicable law or agreed to in writing, software
|
@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 distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
@rem See the License for the specific language governing permissions and
|
@rem See the License for the specific language governing permissions and
|
||||||
@rem limitations under the License.
|
@rem limitations under the License.
|
||||||
@rem
|
@rem
|
||||||
|
@rem SPDX-License-Identifier: Apache-2.0
|
||||||
@if "%DEBUG%"=="" @echo off
|
@rem
|
||||||
@rem ##########################################################################
|
|
||||||
@rem
|
@if "%DEBUG%"=="" @echo off
|
||||||
@rem Gradle startup script for Windows
|
@rem ##########################################################################
|
||||||
@rem
|
@rem
|
||||||
@rem ##########################################################################
|
@rem Gradle startup script for Windows
|
||||||
|
@rem
|
||||||
@rem Set local scope for the variables with windows NT shell
|
@rem ##########################################################################
|
||||||
if "%OS%"=="Windows_NT" setlocal
|
|
||||||
|
@rem Set local scope for the variables with windows NT shell
|
||||||
set DIRNAME=%~dp0
|
if "%OS%"=="Windows_NT" setlocal
|
||||||
if "%DIRNAME%"=="" set DIRNAME=.
|
|
||||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
set DIRNAME=%~dp0
|
||||||
|
if "%DIRNAME%"=="" set DIRNAME=.
|
||||||
@rem Find java.exe
|
@rem This is normally unused
|
||||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
set APP_BASE_NAME=%~n0
|
||||||
|
set APP_HOME=%DIRNAME%
|
||||||
set JAVA_EXE=java.exe
|
|
||||||
%JAVA_EXE% -version >NUL 2>&1
|
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||||
if "%ERRORLEVEL%"=="0" goto execute
|
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||||
|
|
||||||
echo.
|
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||||
echo.
|
|
||||||
echo Please set the JAVA_HOME variable in your environment to match the
|
@rem Find java.exe
|
||||||
location of your Java installation.
|
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||||
|
|
||||||
goto fail
|
set JAVA_EXE=java.exe
|
||||||
|
%JAVA_EXE% -version >NUL 2>&1
|
||||||
:findJavaFromJavaHome
|
if %ERRORLEVEL% equ 0 goto execute
|
||||||
set JAVA_HOME=%JAVA_HOME:"=%
|
|
||||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||||
if exist "%JAVA_EXE%" goto execute
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
echo.
|
echo location of your Java installation. 1>&2
|
||||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
|
|
||||||
echo.
|
goto fail
|
||||||
echo Please set the JAVA_HOME variable in your environment to match the
|
|
||||||
location of your Java installation.
|
:findJavaFromJavaHome
|
||||||
|
set JAVA_HOME=%JAVA_HOME:"=%
|
||||||
goto fail
|
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||||
|
|
||||||
:execute
|
if exist "%JAVA_EXE%" goto execute
|
||||||
@rem Setup the command line
|
|
||||||
|
echo. 1>&2
|
||||||
set CLASSPATH=%DIRNAME%\gradle\wrapper\gradle-wrapper.jar
|
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
|
||||||
@rem Execute Gradle
|
echo location of your Java installation. 1>&2
|
||||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
|
||||||
|
goto fail
|
||||||
:end
|
|
||||||
@rem End local scope for the variables with windows NT shell
|
:execute
|
||||||
if "%ERRORLEVEL%"=="0" goto mainEnd
|
@rem Setup the command line
|
||||||
|
|
||||||
:fail
|
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
|
||||||
rem the _cmd.exe / c's_ return code!
|
|
||||||
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
|
@rem Execute Gradle
|
||||||
exit /b 1
|
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
||||||
|
|
||||||
:mainEnd
|
:end
|
||||||
if "%OS%"=="Windows_NT" endlocal
|
@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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue