Explorar el Código

app updater preimplement

app updater

app updater

app updater
Milan Jurkulak hace 3 años
padre
commit
dc54d48944
Se han modificado 33 ficheros con 846 adiciones y 138 borrados
  1. 17 2
      app/build.gradle.kts
  2. 1 0
      app/src/main/kotlin/org/mjdev/tvapp/data/local/Category.kt
  3. 1 0
      app/src/main/kotlin/org/mjdev/tvapp/data/local/TVChannel.kt
  4. 1 1
      app/src/main/kotlin/org/mjdev/tvapp/ui/pages/AboutPage.kt
  5. 3 2
      app/src/main/kotlin/org/mjdev/tvapp/ui/pages/MainPage.kt
  6. 0 6
      app/src/main/kotlin/org/mjdev/tvapp/viewmodel/DetailViewModel.kt
  7. 1 7
      app/src/main/kotlin/org/mjdev/tvapp/viewmodel/MainViewModel.kt
  8. 4 0
      app/src/main/res/values-cs/strings.xml
  9. 4 0
      app/src/main/res/values-sk/strings.xml
  10. 4 0
      app/src/main/res/values/strings.xml
  11. 1 1
      app/src/main/res/values/themes.xml
  12. 26 0
      buildSrc/src/main/kotlin/org/mjdev/gradle/extensions/BuildConfig.kt
  13. 12 6
      tvlib/build.gradle.kts
  14. 0 4
      tvlib/src/main/AndroidManifest.xml
  15. 10 10
      tvlib/src/main/kotlin/org/mjdev/tvlib/activity/ComposableActivity.kt
  16. 67 0
      tvlib/src/main/kotlin/org/mjdev/tvlib/data/github/remote/Asset.kt
  17. 80 0
      tvlib/src/main/kotlin/org/mjdev/tvlib/data/github/remote/Release.kt
  18. 71 0
      tvlib/src/main/kotlin/org/mjdev/tvlib/data/github/remote/User.kt
  19. 10 0
      tvlib/src/main/kotlin/org/mjdev/tvlib/extensions/ComposeExt.kt
  20. 9 3
      tvlib/src/main/kotlin/org/mjdev/tvlib/extensions/ModifierExt.kt
  21. 59 0
      tvlib/src/main/kotlin/org/mjdev/tvlib/extensions/OkHttpClientExt.kt
  22. 2 2
      tvlib/src/main/kotlin/org/mjdev/tvlib/helpers/coil/AlbumArtDecoder.kt
  23. 15 0
      tvlib/src/main/kotlin/org/mjdev/tvlib/network/NetworkConnectivityService.kt
  24. 1 1
      tvlib/src/main/kotlin/org/mjdev/tvlib/ui/components/audiopreview/dailymotion/DailyMotionVideoView.kt
  25. 48 0
      tvlib/src/main/kotlin/org/mjdev/tvlib/ui/components/tv/AppUpdateAvailableMessage.kt
  26. 34 19
      tvlib/src/main/kotlin/org/mjdev/tvlib/ui/components/tv/BrowseView.kt
  27. 79 71
      tvlib/src/main/kotlin/org/mjdev/tvlib/ui/components/tv/ErrorMessage.kt
  28. 1 2
      tvlib/src/main/kotlin/org/mjdev/tvlib/ui/components/tv/Header.kt
  29. 38 0
      tvlib/src/main/kotlin/org/mjdev/tvlib/ui/components/tv/NetworkErrorMessage.kt
  30. 1 1
      tvlib/src/main/kotlin/org/mjdev/tvlib/ui/components/tv/ScrollableTvLazyColumn.kt
  31. 20 0
      tvlib/src/main/kotlin/org/mjdev/tvlib/updater/AppUpdateService.kt
  32. 223 0
      tvlib/src/main/kotlin/org/mjdev/tvlib/updater/AppUpdater.kt
  33. 3 0
      tvlib/src/main/res/values/strings.xml

+ 17 - 2
app/build.gradle.kts

@@ -65,16 +65,31 @@ android {
 
         signingConfig = signingConfigs[SIGNING_CONFIG_NAME]
 
+        buildConfigField("Boolean", "IS_COMPOSE_DEBUG", "false")
+
         buildConfigField(
             "String",
             "IPTV_API_URL",
             "\"https://iptv-org.github.io/api/\""
         )
 
+        buildConfigField(
+            "String",
+            "GITHUB_USER",
+            "\"mimoccc\""
+        )
+
+        buildConfigField(
+            "String",
+            "GITHUB_REPOSITORY",
+            "\"tvapp\""
+        )
+
         manifestPlaceholders.apply {
             put("auth0Domain", "@string/com_auth0_domain")
             put("auth0Scheme", "demo")
         }
+
     }
 
     buildTypes {
@@ -253,12 +268,12 @@ dependencies {
     implementation("androidx.media3:media3-exoplayer-dash:1.1.0")
     implementation("androidx.media3:media3-exoplayer-hls:1.1.0")
     implementation("androidx.media3:media3-exoplayer-rtsp:1.1.0")
-    implementation("androidx.media3:media3-exoplayer-ima:1.1.0")
+//    implementation("androidx.media3:media3-exoplayer-ima:1.1.0")
     implementation("androidx.media3:media3-datasource-cronet:1.1.0")
     implementation("androidx.media3:media3-datasource-okhttp:1.1.0")
     implementation("androidx.media3:media3-datasource-rtmp:1.1.0")
     implementation("androidx.media3:media3-ui:1.1.0")
-    implementation("androidx.media3:media3-ui-leanback:1.1.0")
+//    implementation("androidx.media3:media3-ui-leanback:1.1.0")
     implementation("androidx.media3:media3-session:1.1.0")
     implementation("androidx.media3:media3-extractor:1.1.0")
     implementation("androidx.media3:media3-cast:1.1.0")

+ 1 - 0
app/src/main/kotlin/org/mjdev/tvapp/data/local/Category.kt

@@ -33,6 +33,7 @@ class Category() :
     }
 
     @Id
+    @Json(name = "id")
     override var id: Long = 0
 
     @Json(name = "title")

+ 1 - 0
app/src/main/kotlin/org/mjdev/tvapp/data/local/TVChannel.kt

@@ -61,6 +61,7 @@ class TVChannel() :
     }
 
     @Id
+    @Json(name = "id")
     override var id: Long = 0
 
     @Json(name = "id")

+ 1 - 1
app/src/main/kotlin/org/mjdev/tvapp/ui/pages/AboutPage.kt

@@ -85,7 +85,7 @@ class AboutPage : Page() {
                             description = {
                                 TextAny(
                                     color = Color.White,
-                                    text = R.string.author
+                                    text = R.string.text_author
                                 )
                             }
                         )

+ 3 - 2
app/src/main/kotlin/org/mjdev/tvapp/ui/pages/MainPage.kt

@@ -20,6 +20,7 @@ import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.remember
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.graphics.vector.ImageVector
+import org.mjdev.tvapp.BuildConfig
 import org.mjdev.tvapp.R
 import org.mjdev.tvapp.activity.IPTVActivity
 import org.mjdev.tvapp.activity.IPTVActivity.Companion.IPTV_DATA
@@ -58,7 +59,6 @@ class MainPage : Page() {
         val countryList = viewModel.countryList.collectAsState()
         val featuredMovieList = viewModel.featuredMovieList.collectAsState()
         val messages = viewModel.messages.collectAsState()
-        val networkState = viewModel.networkInfo.networkStatus.collectAsState(null)
 
         val errorState = remember(viewModel.error) { mutableStateOf(viewModel.error.value) }
         val titleState = remember { mutableStateOf<Any?>(R.string.app_name) }
@@ -103,12 +103,13 @@ class MainPage : Page() {
                 modifier = Modifier.fillMaxSize(),
                 appIcon = R.mipmap.ic_launcher_foreground,
                 userIcon = user.value?.pictureUrl ?: org.mjdev.tvlib.R.drawable.person,
+                githubUser = BuildConfig.GITHUB_USER,
+                githubRepository = BuildConfig.GITHUB_REPOSITORY,
                 title = titleState.value,
                 messages = messages.value,
                 categories = countryList.value,
                 featuredItems = featuredMovieList.value,
                 categoriesAndItemsMap = streamingData.value,
-                networkState = networkState,
                 errorState = errorState,
                 onTitleClicked = {
                     navController?.openMenu()

+ 0 - 6
app/src/main/kotlin/org/mjdev/tvapp/viewmodel/DetailViewModel.kt

@@ -16,14 +16,12 @@ import org.mjdev.tvapp.data.local.Movie
 import org.mjdev.tvlib.helpers.cursor.AudioCursor
 import org.mjdev.tvlib.helpers.cursor.PhotoCursor
 import org.mjdev.tvlib.helpers.cursor.VideoCursor
-import org.mjdev.tvlib.network.NetworkConnectivityService
 import org.mjdev.tvlib.viewmodel.BaseViewModel
 import org.mjdev.tvapp.database.DAO
 import org.mjdev.tvlib.extensions.MediaItemExt.mediaItem
 import org.mjdev.tvlib.interfaces.ItemAudio
 import org.mjdev.tvlib.interfaces.ItemPhoto
 import org.mjdev.tvlib.interfaces.ItemVideo
-import org.mjdev.tvlib.network.NetworkConnectivityServiceImpl
 import javax.inject.Inject
 import kotlin.reflect.KClass
 
@@ -34,9 +32,6 @@ class DetailViewModel @Inject constructor() : BaseViewModel() {
     @Inject
     lateinit var dao: DAO
 
-    @Inject
-    lateinit var networkInfo: NetworkConnectivityService
-
     @Inject
     lateinit var localAudioCursor: AudioCursor
 
@@ -76,7 +71,6 @@ class DetailViewModel @Inject constructor() : BaseViewModel() {
             context: Context
         ): DetailViewModel = DetailViewModel().apply {
             dao = DAO(context)
-            networkInfo = NetworkConnectivityServiceImpl(context)
             localAudioCursor = AudioCursor(context)
             localVideoCursor = VideoCursor(context)
             localPhotoCursor = PhotoCursor(context)

+ 1 - 7
app/src/main/kotlin/org/mjdev/tvapp/viewmodel/MainViewModel.kt

@@ -22,14 +22,12 @@ import org.mjdev.tvlib.extensions.ListExt.asMap
 import org.mjdev.tvlib.helpers.cursor.AudioCursor
 import org.mjdev.tvlib.helpers.cursor.PhotoCursor
 import org.mjdev.tvlib.helpers.cursor.VideoCursor
-import org.mjdev.tvlib.network.NetworkConnectivityService
 import org.mjdev.tvlib.viewmodel.BaseViewModel
 import org.mjdev.tvapp.data.local.Message
 import org.mjdev.tvapp.data.local.Movie
 import org.mjdev.tvapp.data.local.Movie_
 import org.mjdev.tvapp.database.DAO
 import org.mjdev.tvlib.helpers.apps.appsManager
-import org.mjdev.tvlib.network.NetworkConnectivityServiceImpl
 import javax.inject.Inject
 
 @HiltViewModel
@@ -41,9 +39,6 @@ class MainViewModel @Inject constructor(
     @Inject
     lateinit var dao: DAO
 
-    @Inject
-    lateinit var networkInfo: NetworkConnectivityService
-
     @Inject
     lateinit var localAudioCursor: AudioCursor
 
@@ -54,7 +49,7 @@ class MainViewModel @Inject constructor(
     lateinit var localPhotoCursor: PhotoCursor
 
     private val noCategoryString by lazy {
-        context.getString(R.string.no_category)
+        context.getString(R.string.title_no_category)
     }
 
     val apps = appsManager(
@@ -126,7 +121,6 @@ class MainViewModel @Inject constructor(
             context: Context
         ): MainViewModel = MainViewModel(context).apply {
             dao = DAO(context)
-            networkInfo = NetworkConnectivityServiceImpl(context)
             localAudioCursor = AudioCursor(context)
             localVideoCursor = VideoCursor(context)
             localPhotoCursor = PhotoCursor(context)

+ 4 - 0
app/src/main/res/values-cs/strings.xml

@@ -13,6 +13,7 @@
     <string name="app_name">Tv App</string>
 
     <string name="bt_dismiss">Zrušit</string>
+    <string name="bt_update">Aktualizovat</string>
 
     <string name="error_no_network">Internet není k dispozici.</string>
     <string name="error_unknown">Neznáma chyba.</string>
@@ -29,9 +30,12 @@
 
     <string name="text_app_description">\n\n\nSimple TV Application for AndroidTV or SetTopBox with Android or any tablet or mobile device.\nAll in one solution for mobile app responsive to device.\nThis app is just an simple demonstration of Jetpack Compose and all other modern architecture components.\n\n\n</string>
     <string name="text_author">Vytvorené autorem Milan Jurkulák © 2023</string>
+    <string name="text_unimplemented_yet">Funkce zatím není implementována.</string>
+    <string name="text_update_message">Aktualizace aplikace připravena.</string>
 
     <string name="title_about">O aplikaci</string>
     <string name="title_apps">Aplikace</string>
+    <string name="title_app_update">Update</string>
     <string name="title_error">Chyba</string>
     <string name="title_home">Domov</string>
     <string name="title_movie_detail">Detail videa</string>

+ 4 - 0
app/src/main/res/values-sk/strings.xml

@@ -13,6 +13,7 @@
     <string name="app_name">Tv App</string>
 
     <string name="bt_dismiss">Zrušiť</string>
+    <string name="bt_update">Aktualizovať</string>
 
     <string name="error_no_network">Internet nie je pripojený.</string>
     <string name="error_unknown">Neznáma chyba.</string>
@@ -29,9 +30,12 @@
 
     <string name="text_app_description">\n\n\nSimple TV Application for AndroidTV or SetTopBox with Android or any tablet or mobile device.\nAll in one solution for mobile app responsive to device.\nThis app is just an simple demonstration of Jetpack Compose and all other modern architecture components.\n\n\n</string>
     <string name="text_author">Vytvorené autorom Milan Jurkulák © 2023</string>
+    <string name="text_unimplemented_yet">Funkce zatiaľ nie je implementovaná.</string>
+    <string name="text_update_message">Aktualizácia aplikácie je pripravená.</string>
 
     <string name="title_about">O aplikácií</string>
     <string name="title_apps">Aplikácie</string>
+    <string name="title_app_update">Update</string>
     <string name="title_error">Chyba</string>
     <string name="title_home">Domov</string>
     <string name="title_movie_detail">Detail videa</string>

+ 4 - 0
app/src/main/res/values/strings.xml

@@ -13,6 +13,7 @@
     <string name="app_name">Tv App</string>
 
     <string name="bt_dismiss">Dismiss</string>
+    <string name="bt_update">Update</string>
 
     <string name="error_no_network">Internet connection is missing.</string>
     <string name="error_unknown">Unknown error.</string>
@@ -29,9 +30,12 @@
 
     <string name="text_app_description">\n\n\nSimple TV Application for AndroidTV or SetTopBox with Android or any tablet or mobile device.\nAll in one solution for mobile app responsive to device.\nThis app is just an simple demonstration of Jetpack Compose and all other modern architecture components.\n\n\n</string>
     <string name="text_author">Created by Milan Jurkulak © 2023</string>
+    <string name="text_unimplemented_yet">This feature is not yet implemented.</string>
+    <string name="text_update_message">Update ready to install.</string>
 
     <string name="title_about">About</string>
     <string name="title_apps">Applications</string>
+    <string name="title_app_update">Update</string>
     <string name="title_error">Error</string>
     <string name="title_home">Home</string>
     <string name="title_movie_detail">Movie detail</string>

+ 1 - 1
app/src/main/res/values/themes.xml

@@ -14,7 +14,7 @@
         <item name="android:windowNoTitle">true</item>
         <item name="android:windowBackground">#FF444444</item>
         <item name="android:navigationBarColor">#00000000</item>
-        <item name="statusBarBackground">#00000000</item>
+<!--        <item name="statusBarBackground">#00000000</item>-->
         <item name="android:statusBarColor">#00000000</item>
         <item name="android:colorBackground">#FF444444</item>
         <item name="android:windowTranslucentStatus">true</item>

+ 26 - 0
buildSrc/src/main/kotlin/org/mjdev/gradle/extensions/BuildConfig.kt

@@ -0,0 +1,26 @@
+/*
+ *  Copyright (c) Milan Jurkulák 2023.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */
+
+package org.mjdev.gradle.extensions
+
+//todo
+
+@Suppress("unused")
+object BuildConfig {
+
+//    fun VariantDimension.configFields(block: com.android.build.api.dsl.DefaultConfig.() -> Unit) {
+//        block.invoke(this)
+//    }
+
+//    fun DefaultConfig.boolean(vararg fields: Pair<String, Boolean>) {
+//        fields.forEach { pair ->
+//            buildConfigField("Boolean", pair.first, pair.second.toString())
+//        }
+//    }
+
+}

+ 12 - 6
tvlib/build.gradle.kts

@@ -32,6 +32,7 @@ android {
         minSdk = projectMinSdk
         testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
         consumerProguardFiles("consumer-rules.pro")
+        buildConfigField("Boolean", "IS_COMPOSE_DEBUG", "false")
     }
 
     buildTypes {
@@ -85,7 +86,7 @@ android {
 dependencies {
     // base libs
     implementation("androidx.core:core-ktx:1.12.0-rc01")
-    implementation ("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
+    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
     // reflect / debug purposes only
     implementation("org.jetbrains.kotlin:kotlin-reflect:1.9.0")
     // window manager
@@ -104,14 +105,19 @@ dependencies {
     // navigation
     implementation("androidx.navigation:navigation-compose:2.7.0")
     // okhttp
-    implementation("com.squareup.okhttp3:okhttp:4.11.0")
-    implementation("com.squareup.okhttp3:logging-interceptor:4.11.0")
+    implementation(platform("com.squareup.okhttp3:okhttp-bom:5.0.0-alpha.11"))
+    implementation("com.squareup.okhttp3:okhttp")
+    implementation("com.squareup.okhttp3:logging-interceptor:5.0.0-alpha.11")
+    // retrofit
+    implementation("com.squareup.retrofit2:retrofit:2.9.0")
+    implementation("com.squareup.retrofit2:converter-moshi:2.9.0")
+    implementation("com.jakewharton.retrofit:retrofit2-kotlin-coroutines-adapter:0.9.2")
     // moshi json
     implementation("com.squareup.moshi:moshi:1.15.0")
     implementation("com.squareup.retrofit2:converter-moshi:2.9.0")
     ksp("com.squareup.moshi:moshi-kotlin-codegen:1.15.0")
     // sandwich
-    implementation ("com.github.skydoves:sandwich:1.3.8")
+    implementation("com.github.skydoves:sandwich:1.3.8")
     // image loading
     implementation("io.coil-kt:coil-base:2.4.0")
     implementation("io.coil-kt:coil-gif:2.4.0")
@@ -140,12 +146,12 @@ dependencies {
     implementation("androidx.media3:media3-exoplayer-dash:1.1.0")
     implementation("androidx.media3:media3-exoplayer-hls:1.1.0")
     implementation("androidx.media3:media3-exoplayer-rtsp:1.1.0")
-    implementation("androidx.media3:media3-exoplayer-ima:1.1.0")
+//    implementation("androidx.media3:media3-exoplayer-ima:1.1.0")
     implementation("androidx.media3:media3-datasource-cronet:1.1.0")
     implementation("androidx.media3:media3-datasource-okhttp:1.1.0")
     implementation("androidx.media3:media3-datasource-rtmp:1.1.0")
     implementation("androidx.media3:media3-ui:1.1.0")
-    implementation("androidx.media3:media3-ui-leanback:1.1.0")
+//    implementation("androidx.media3:media3-ui-leanback:1.1.0")
     implementation("androidx.media3:media3-session:1.1.0")
     implementation("androidx.media3:media3-extractor:1.1.0")
     implementation("androidx.media3:media3-cast:1.1.0")

+ 0 - 4
tvlib/src/main/AndroidManifest.xml

@@ -8,10 +8,6 @@
 <manifest xmlns:android="http://schemas.android.com/apk/res/android"
     xmlns:tools="http://schemas.android.com/tools">
 
-    <!--    <permission-->
-    <!--        android:name="android.permission.QUERY_ALL_PACKAGES"-->
-    <!--        tools:ignore="ReservedSystemPermission" />-->
-
     <queries>
         <intent>
             <action android:name="android.intent.action.MAIN" />

+ 10 - 10
tvlib/src/main/kotlin/org/mjdev/tvlib/activity/ComposableActivity.kt

@@ -48,7 +48,7 @@ import timber.log.Timber
 open class ComposableActivity : ComponentActivity() {
 
     @Suppress("PropertyName")
-    val ANR_TIMEOUT = 2000
+    val ANR_TIMEOUT = 5000
 
     val activityResultListeners = mutableListOf<ActivityResultHandler<*>>()
 
@@ -64,6 +64,14 @@ open class ComposableActivity : ComponentActivity() {
 
     var lastIntent: Intent? = null
 
+    val anrWatchDog: ANRWatchDog by lazy {
+        ANRWatchDog(ANR_TIMEOUT)
+            .setIgnoreDebugger(true)
+            .setANRListener { anr ->
+                anr.printStackTrace()
+            }
+    }
+
     @TvPreview
     @OptIn(ExperimentalTvMaterial3Api::class)
     @Composable
@@ -127,7 +135,7 @@ open class ComposableActivity : ComponentActivity() {
         Timber.d("Activity ${this::class.simpleName} created.")
         super.onCreate(savedInstanceState)
         if (BuildConfig.DEBUG) {
-            reportANR()
+            anrWatchDog.start()
         }
         WindowCompat.setDecorFitsSystemWindows(window, !isATv)
         setContent {
@@ -157,12 +165,4 @@ open class ComposableActivity : ComponentActivity() {
         onActivityResult
     )
 
-    fun reportANR() {
-        ANRWatchDog(ANR_TIMEOUT)
-            .setIgnoreDebugger(true)
-            .setANRListener { anr ->
-                anr.printStackTrace()
-            }.start()
-    }
-
 }

+ 67 - 0
tvlib/src/main/kotlin/org/mjdev/tvlib/data/github/remote/Asset.kt

@@ -0,0 +1,67 @@
+/*
+ *  Copyright (c) Milan Jurkulák 2023.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */
+
+package org.mjdev.tvlib.data.github.remote
+
+import com.squareup.moshi.Json
+import com.squareup.moshi.JsonClass
+
+@JsonClass(generateAdapter = true)
+data class Asset(
+
+    @Json(name = "id")
+    val id: Long = 0L,
+
+    @Json(name = "node_id")
+    val nodeId: String = "",
+
+    @Json(name = "name")
+    val name: String = "",
+
+    @Json(name = "url")
+    val url: String = "",
+
+    @Json(name = "label")
+    val label: String = "",
+
+    @Json(name = "uploader")
+    val uploader: User = User(),
+
+    @Json(name = "author")
+    val author: User = User(),
+
+    @Json(name = "content_type")
+    val contentType: String = "",
+
+    @Json(name = "state")
+    val state: String = "",
+
+    @Json(name = "size")
+    val size: Long = 0L,
+
+    @Json(name = "download_count")
+    val downloadCount: Long = 0L,
+
+    @Json(name = "created_at")
+    val createdAt: String = "",
+
+    @Json(name = "updated_at")
+    val updatedAt: String = "",
+
+    @Json(name = "browser_download_url")
+    val downloadUrl: String = ""
+
+) {
+
+    val isAPK : Boolean get() = contentType
+        .contentEquals("application/vnd.android.package-archive")
+
+    val isDownloadable get() = state
+        .contentEquals("uploaded") && downloadUrl.isNotEmpty()
+
+}

+ 80 - 0
tvlib/src/main/kotlin/org/mjdev/tvlib/data/github/remote/Release.kt

@@ -0,0 +1,80 @@
+/*
+ *  Copyright (c) Milan Jurkulák 2023.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */
+
+package org.mjdev.tvlib.data.github.remote
+
+import com.squareup.moshi.Json
+import com.squareup.moshi.JsonClass
+
+@Suppress("MemberVisibilityCanBePrivate")
+@JsonClass(generateAdapter = true)
+data class Release(
+
+    @Json(name = "id")
+    val id: Long = 0L,
+
+    @Json(name = "url")
+    val url: String = "",
+
+    @Json(name = "assets_url")
+    val assetsUrl: String = "",
+
+    @Json(name = "upload_url")
+    val uploadUrl: String = "",
+
+    @Json(name = "html_url")
+    val htmlUrl: String = "",
+
+    @Json(name = "author")
+    val author: User = User(),
+
+    @Json(name = "node_id")
+    val nodeId: String = "",
+
+    @Json(name = "tag_name")
+    val tagName: String = "",
+
+    @Json(name = "target_commitish")
+    val targetCommitish: String = "",
+
+    @Json(name = "name")
+    val name: String = "",
+
+    @Json(name = "draft")
+    val draft: Boolean = false,
+
+    @Json(name = "prerelease")
+    val preRelease: Boolean = false,
+
+    @Json(name = "created_at")
+    val createdAt: String = "",
+
+    @Json(name = "published_at")
+    val publishedAt: String = "",
+
+    @Json(name = "assets")
+    val assets: List<Asset> = listOf(),
+
+    @Json(name = "tarball_url")
+    val tarUrl: String = "",
+
+    @Json(name = "zipball_url")
+    val zipUrl: String = ""
+
+) {
+    val hasAssets: Boolean get() = assets.isNotEmpty()
+
+    val hasAPK: Boolean
+        get() = hasAssets && assets.any { asset ->
+            asset.isAPK && asset.isDownloadable
+        }
+
+    val apkAsset: Asset?
+        get() = assets.firstOrNull { asset -> asset.isAPK }
+
+}

+ 71 - 0
tvlib/src/main/kotlin/org/mjdev/tvlib/data/github/remote/User.kt

@@ -0,0 +1,71 @@
+/*
+ *  Copyright (c) Milan Jurkulák 2023.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */
+
+package org.mjdev.tvlib.data.github.remote
+
+import com.squareup.moshi.Json
+import com.squareup.moshi.JsonClass
+
+@JsonClass(generateAdapter = true)
+data class User(
+
+    @Json(name = "id")
+    val id: Long = 0L,
+
+    @Json(name = "node_id")
+    val nodeId: String = "",
+
+    @Json(name = "login")
+    val login: String = "",
+
+    @Json(name = "avatar_url")
+    val avatarUrl: String = "",
+
+    @Json(name = "gravatar_id")
+    val gravatarId: String = "",
+
+    @Json(name = "url")
+    val url: String = "",
+
+    @Json(name = "html_url")
+    val htmlUrl: String = "",
+
+    @Json(name = "followers_url")
+    val followersUrl: String = "",
+
+    @Json(name = "following_url")
+    val followingUrl: String = "",
+
+    @Json(name = "gists_url")
+    val gistsUrl: String = "",
+
+    @Json(name = "starred_url")
+    val starredUrl: String = "",
+
+    @Json(name = "subscriptions_url")
+    val subscriptionsUrl: String = "",
+
+    @Json(name = "organizations_url")
+    val organizationsUrl: String = "",
+
+    @Json(name = "repos_url")
+    val reposUrl: String = "",
+
+    @Json(name = "events_url")
+    val eventsUrl: String = "",
+
+    @Json(name = "received_events_url")
+    val receivedEventsUrl: String = "",
+
+    @Json(name = "type")
+    val type: String = "",
+
+    @Json(name = "site_admin")
+    val siteAdmin: Boolean = false
+
+)

+ 10 - 0
tvlib/src/main/kotlin/org/mjdev/tvlib/extensions/ComposeExt.kt

@@ -27,6 +27,7 @@ import androidx.compose.ui.focus.FocusState
 import androidx.compose.ui.platform.LocalConfiguration
 import androidx.compose.ui.platform.LocalContext
 import androidx.compose.ui.platform.LocalInspectionMode
+import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.unit.Dp
 import androidx.compose.ui.unit.dp
 import androidx.tv.material3.DrawerState
@@ -65,6 +66,15 @@ object ComposeExt {
     @Composable
     fun isAtv(): Boolean = LocalContext.current.isATv
 
+    // todo more types
+    @Composable
+    fun Any?.asException() : Exception? = when (this) {
+        null -> null
+        is String -> Exception(this)
+        is Int -> Exception(stringResource(this))
+        else -> Exception(this.toString())
+    }
+
     @OptIn(ExperimentalTvMaterial3Api::class)
     val DrawerState.isOpen: Boolean
         get() = currentValue == DrawerValue.Open

+ 9 - 3
tvlib/src/main/kotlin/org/mjdev/tvlib/extensions/ModifierExt.kt

@@ -43,6 +43,7 @@ import androidx.compose.ui.layout.onSizeChanged
 import androidx.compose.ui.platform.debugInspectorInfo
 import androidx.compose.ui.unit.dp
 import kotlinx.coroutines.delay
+import org.mjdev.tvlib.BuildConfig
 import timber.log.Timber
 import java.lang.Float.min
 import kotlin.math.abs
@@ -51,7 +52,10 @@ import kotlin.math.abs
 object ModifierExt {
 
     // used to debug library
-    const val isDebug = false
+    val isDebug: Boolean = BuildConfig.DEBUG
+
+    // used to debug composable in library
+    val isComposeDebug: Boolean = BuildConfig.IS_COMPOSE_DEBUG
 
     fun Modifier.conditional(
         condition: Boolean,
@@ -102,8 +106,10 @@ object ModifierExt {
 
     @Stable
     fun Modifier.recomposeHighlighter(): Modifier = conditional(
-        isDebug
-    ) { this.then(recomposeModifier) }
+        isComposeDebug
+    ) {
+        this.then(recomposeModifier)
+    }
 
     @SuppressLint("AutoboxingStateValueProperty")
     private val recomposeModifier = Modifier.composed(

+ 59 - 0
tvlib/src/main/kotlin/org/mjdev/tvlib/extensions/OkHttpClientExt.kt

@@ -0,0 +1,59 @@
+/*
+ *  Copyright (c) Milan Jurkulák 2023.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */
+
+@file:Suppress("unused")
+
+package org.mjdev.tvlib.extensions
+
+import okhttp3.Call
+import okhttp3.Callback
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okhttp3.Response
+
+object OkHttpClientExt {
+
+    fun OkHttpClient.download(
+        url: String,
+        onResult: (Result<ByteArray>) -> Unit
+    ) {
+        Request.Builder().url(url).build().also { request ->
+            newCall(request).enqueue(
+                object : Callback {
+                    override fun onFailure(call: Call, e: okio.IOException) {
+                        onResult.invoke(Result.failure(e))
+                    }
+
+                    override fun onResponse(call: Call, response: Response) {
+                        onResult.invoke(Result.success(response.body.bytes()))
+                    }
+                }
+            )
+        }
+    }
+
+//    fun OkHttpClient.download(
+//        url: String,
+//        onResult: (Result<String>) -> Unit
+//    ) {
+//        Request.Builder().url(url).build().also { request ->
+//            newCall(request).enqueue(
+//                object : Callback {
+//                    override fun onFailure(call: Call, e: okio.IOException) {
+//                        onResult.invoke(Result.failure(e))
+//                    }
+//
+//                    override fun onResponse(call: Call, response: Response) {
+//                        onResult.invoke(Result.success(response.body.string()))
+//                    }
+//                }
+//            )
+//        }
+//    }
+
+}

+ 2 - 2
tvlib/src/main/kotlin/org/mjdev/tvlib/helpers/coil/AlbumArtDecoder.kt

@@ -127,7 +127,7 @@ class AlbumArtDecoder(
             val ins = try {
                 val response = call(url)
                 if (response.code == 200) {
-                    response.body?.byteStream()
+                    response.body.byteStream()
                 } else {
                     Timber.e(response.message)
                     null
@@ -145,7 +145,7 @@ class AlbumArtDecoder(
     private fun retrieveXMLFromURL(url: String): InputStream? = try {
         val response = call(url)
         if (response.code == 200) {
-            response.body?.byteStream()
+            response.body.byteStream()
         } else {
             Timber.e(response.message)
             null

+ 15 - 0
tvlib/src/main/kotlin/org/mjdev/tvlib/network/NetworkConnectivityService.kt

@@ -8,10 +8,25 @@
 
 package org.mjdev.tvlib.network
 
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.platform.LocalContext
 import kotlinx.coroutines.flow.Flow
 
 interface NetworkConnectivityService {
 
     val networkStatus: Flow<NetworkStatus?>
 
+    companion object {
+
+        @Composable
+        fun rememberNetworkService(): NetworkConnectivityService {
+            val context = LocalContext.current
+            return remember {
+                NetworkConnectivityServiceImpl(context)
+            }
+        }
+
+    }
+
 }

+ 1 - 1
tvlib/src/main/kotlin/org/mjdev/tvlib/ui/components/audiopreview/dailymotion/DailyMotionVideoView.kt

@@ -156,7 +156,7 @@ class DailyMotionVideoView @JvmOverloads constructor(
                         DM_URL,
                         URLEncoder.encode(q)
                     )
-                    val json = call(url).body?.string() ?: ""
+                    val json = call(url).body.string()
                     val result: SearchResult? = fromSimpleJSONString<SearchResult>(json)
                     val videoId = if (result?.list != null && result.list.isNotEmpty())
                         result.list[0].id

+ 48 - 0
tvlib/src/main/kotlin/org/mjdev/tvlib/ui/components/tv/AppUpdateAvailableMessage.kt

@@ -0,0 +1,48 @@
+/*
+ *  Copyright (c) Milan Jurkulák 2023.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */
+
+package org.mjdev.tvlib.ui.components.tv
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.tooling.preview.Preview
+import org.mjdev.tvlib.R
+import org.mjdev.tvlib.extensions.ComposeExt.asException
+import org.mjdev.tvlib.updater.AppUpdater
+import org.mjdev.tvlib.updater.AppUpdater.Companion.rememberAppUpdater
+
+@Preview
+@Composable
+fun AppUpdateAvailableMessage(
+    title: Any? = R.string.title_app_update,
+    message: Any? = R.string.msg_update_available,
+    githubUser: String = "mimoccc",
+    githubRepository: String = "tvapp",
+    backgroundColor: Color = Color(0, 132, 0, 255),
+    buttonText:Any? = R.string.bt_update,
+    appUpdater: AppUpdater = rememberAppUpdater(
+        githubUser = githubUser,
+        githubRepository = githubRepository
+    ),
+    dismissState: MutableState<Boolean> = mutableStateOf(false),
+    onButtonClick : ()->Unit = { appUpdater.updateApp() },
+) {
+    if (appUpdater.isUpdateAvailable) {
+        ErrorMessage(
+            title = title,
+            dismissText = buttonText,
+            error = message.asException(),
+            backgroundColor = backgroundColor,
+            dismissible = true,
+            dismissState = dismissState,
+            dismissOnClick = onButtonClick
+        )
+    }
+}

+ 34 - 19
tvlib/src/main/kotlin/org/mjdev/tvlib/ui/components/tv/BrowseView.kt

@@ -9,22 +9,20 @@
 package org.mjdev.tvlib.ui.components.tv
 
 import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.PaddingValues
 import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.heightIn
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.State
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.unit.dp
 import org.mjdev.tvlib.annotations.TvPreview
 import org.mjdev.tvlib.extensions.ComposeExt.isEditMode
 import org.mjdev.tvlib.extensions.ModifierExt.recomposeHighlighter
-import org.mjdev.tvlib.extensions.StringExt.asException
 import org.mjdev.tvlib.interfaces.ItemWithTitle
-import org.mjdev.tvlib.network.NetworkStatus
-import org.mjdev.tvlib.network.isNotConnected
 import org.mjdev.tvlib.R
 import org.mjdev.tvlib.ui.components.carousel.BigCarousel
 
@@ -34,9 +32,12 @@ fun BrowseView(
     modifier: Modifier = Modifier,
     showHeader: Boolean = true,
     showNetworkState: Boolean = true,
+    showAppUpdateState: Boolean = true,
     title: Any? = "test",
     appIcon: Any? = R.drawable.person,
     userIcon: Any? = R.drawable.person,
+    githubUser: String = "mimoccc",
+    githubRepository: String = "tvapp",
     messages: List<Any?> = listOf(Unit, Unit, Unit),
     categories: List<Any?> = listOf(Unit, Unit, Unit),
     featuredItems: List<Any?> = listOf(Unit, Unit, Unit),
@@ -45,7 +46,6 @@ fun BrowseView(
         Unit to listOf(Unit, Unit, Unit, Unit),
         Unit to listOf(Unit, Unit, Unit, Unit),
     ),
-    networkState: State<NetworkStatus?> = mutableStateOf(NetworkStatus.Unknown),
     errorState: State<Throwable?> = mutableStateOf(null),
     verticalArrangement: Arrangement.Vertical = Arrangement.spacedBy(32.dp),
     contentPadding: PaddingValues = PaddingValues(8.dp),
@@ -58,7 +58,7 @@ fun BrowseView(
     onItemClicked: (item: Any?) -> Unit = {}
 ) {
     val isEdit = isEditMode()
-    ScrollableTvLazyRow(
+    ScrollableTvLazyColumn(
         modifier = modifier
             .recomposeHighlighter()
             .fillMaxSize(),
@@ -77,18 +77,33 @@ fun BrowseView(
                 onUserPicClick = onUserPicClicked,
             )
         }
-        if (isEdit || (showNetworkState && networkState.isNotConnected)) item {
-            ErrorMessage(
-                error = stringResource(R.string.error_no_network).asException(),
-                backgroundColor = Color.Black,
-                dismissible = false
-            )
-        }
-        if (isEdit || (errorState.value is Exception)) item {
-            ErrorMessage(
-                error = errorState.value,
-                dismissible = false
-            )
+        item {
+            Column(
+                modifier = Modifier
+                    .fillMaxWidth()
+                    .heightIn(min = 0.dp)
+            ) {
+                if (isEdit || showNetworkState) {
+                    NetworkErrorMessage(
+                        message = R.string.error_no_network,
+                        dismissible = false
+                    )
+                }
+                if (isEdit || showAppUpdateState) {
+                    AppUpdateAvailableMessage(
+                        githubUser = githubUser,
+                        githubRepository = githubRepository,
+                        title = R.string.title_app_update,
+                        message = R.string.msg_update_available,
+                    )
+                }
+                if (isEdit || (errorState.value is Exception)) {
+                    ErrorMessage(
+                        error = errorState.value,
+                        dismissible = false
+                    )
+                }
+            }
         }
         if (isEdit || categories.isNotEmpty()) item {
             Tabs(

+ 79 - 71
tvlib/src/main/kotlin/org/mjdev/tvlib/ui/components/tv/ErrorMessage.kt

@@ -18,6 +18,8 @@ import androidx.compose.foundation.layout.wrapContentSize
 import androidx.compose.foundation.shape.RoundedCornerShape
 import androidx.compose.foundation.text.BasicText
 import androidx.compose.runtime.Composable
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.mutableStateOf
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.graphics.Color
@@ -26,6 +28,7 @@ import androidx.compose.ui.graphics.takeOrElse
 import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.text.font.FontWeight
 import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.Preview
 import androidx.compose.ui.unit.Dp
 import androidx.compose.ui.unit.TextUnit
 import androidx.compose.ui.unit.dp
@@ -33,98 +36,103 @@ import androidx.compose.ui.unit.sp
 import androidx.tv.material3.ExperimentalTvMaterial3Api
 import androidx.tv.material3.LocalContentColor
 import androidx.tv.material3.LocalTextStyle
-import org.mjdev.tvlib.annotations.TvPreview
 import org.mjdev.tvlib.extensions.ModifierExt.recomposeHighlighter
 import org.mjdev.tvlib.ui.components.button.Button
 import org.mjdev.tvlib.R
 import org.mjdev.tvlib.ui.components.text.TextAny
 
 @OptIn(ExperimentalTvMaterial3Api::class)
-@TvPreview
+@Preview
 @Composable
 fun ErrorMessage(
     error: Throwable? = null,
-    contentPadding: Dp = 4.dp,
+    contentPadding: Dp = 2.dp,
     title: Any? = null,
-    fontSizeTitle: TextUnit = 24.sp,
-    fontSizeMessage: TextUnit = 14.sp,
+    fontSizeTitle: TextUnit = 18.sp,
+    fontSizeMessage: TextUnit = 12.sp,
     fontWeight: FontWeight = FontWeight.Bold,
     color: Color = Color.White,
     paddingTitle: Dp = 4.dp,
     paddingMessage: Dp = paddingTitle,
     backgroundColor: Color = Color.Red,
     roundSize: Dp = 8.dp,
-    cancelText: Any? = stringResource(id = R.string.bt_dismiss),
+    dismissText: Any? = R.string.bt_dismiss,
     shape: Shape = RoundedCornerShape(roundSize),
     dismissible: Boolean = true,
-    onDismiss: () -> Unit = {}
-) = Row(
-    modifier = Modifier
-        .fillMaxWidth()
-        .background(backgroundColor, shape)
-        .padding(contentPadding)
-        .recomposeHighlighter(),
-    verticalAlignment = Alignment.CenterVertically,
+    dismissState: MutableState<Boolean> = mutableStateOf(false),
+    dismissOnClick: () -> Unit = {},
+    customButtons: @Composable () -> Unit = {},
 ) {
-    val style = LocalTextStyle.current
-    val textColor = color.takeOrElse {
-        style.color.takeOrElse {
-            LocalContentColor.current
-        }
-    }
-    TextAny(
-        modifier = Modifier
-            .recomposeHighlighter()
-            .wrapContentSize()
-            .padding(paddingTitle),
-        text = title ?: stringResource(id = R.string.title_error),
-        fontWeight = fontWeight,
-        fontSize = fontSizeTitle,
-        color = color
-    )
-    Spacer(
-        modifier = Modifier.weight(1f)
-    )
-    BasicText(
-        modifier = Modifier
-            .recomposeHighlighter()
-            .wrapContentSize()
-            .padding(paddingMessage),
-        style = style.merge(
-            color = textColor,
-            fontSize = fontSizeMessage,
-            fontWeight = fontWeight,
-            textAlign = TextAlign.Left,
-        ),
-        minLines = 1,
-        maxLines = 4,
-        text =
-        error?.message ?: (stringResource(id = R.string.error_unknown) +
-                "\nType: " +
-                (error?.javaClass?.simpleName ?: Exception::class.simpleName))
-    )
-    if (dismissible && (cancelText != null)) {
-        Spacer(
-            modifier = Modifier.weight(1f)
-        )
-        Button(
+    if (!dismissState.value) {
+        Row(
             modifier = Modifier
-                .recomposeHighlighter()
-                .padding(4.dp),
-            contentPadding = PaddingValues(
-                8.dp,
-                4.dp
-            ),
-            containerColor = Color.Red,
-            onClick = {
-                onDismiss()
-            },
-            content = {
-                TextAny(
-                    text = cancelText,
-                    color = color
+                .fillMaxWidth()
+                .background(backgroundColor, shape)
+                .padding(contentPadding)
+                .recomposeHighlighter(),
+            verticalAlignment = Alignment.CenterVertically,
+        ) {
+            val style = LocalTextStyle.current
+            val textColor = color.takeOrElse {
+                style.color.takeOrElse {
+                    LocalContentColor.current
+                }
+            }
+            TextAny(
+                modifier = Modifier
+                    .recomposeHighlighter()
+                    .wrapContentSize()
+                    .padding(paddingTitle),
+                text = title ?: stringResource(id = R.string.title_error),
+                fontWeight = fontWeight,
+                fontSize = fontSizeTitle,
+                color = color
+            )
+            Spacer(
+                modifier = Modifier.weight(1f, true)
+            )
+            BasicText(
+                modifier = Modifier
+                    .recomposeHighlighter()
+                    .wrapContentSize()
+                    .padding(paddingMessage),
+                style = style.merge(
+                    color = textColor,
+                    fontSize = fontSizeMessage,
+                    fontWeight = fontWeight,
+                    textAlign = TextAlign.Left,
+                ),
+                minLines = 1,
+                maxLines = 4,
+                text = error?.message ?: (stringResource(id = R.string.error_unknown))
+            )
+            if (dismissible && (dismissText != null)) {
+                Spacer(
+                    modifier = Modifier.weight(1f, true)
+                )
+                customButtons()
+                Button(
+                    modifier = Modifier
+                        .recomposeHighlighter()
+                        .padding(4.dp),
+                    contentPadding = PaddingValues(
+                        8.dp,
+                        4.dp
+                    ),
+                    containerColor = backgroundColor,
+                    onClick = {
+                        dismissState.value = true
+                        dismissOnClick.invoke()
+                    },
+                    content = {
+                        TextAny(
+                            text = dismissText,
+                            color = color,
+                            maxLines = 1
+                        )
+                    }
                 )
             }
-        )
+        }
     }
 }

+ 1 - 2
tvlib/src/main/kotlin/org/mjdev/tvlib/ui/components/tv/Header.kt

@@ -124,8 +124,7 @@ fun Header(
                 modifier = Modifier
                     .recomposeHighlighter()
                     .wrapContentHeight()
-                    .width(2.dp)
-                    .clip(CircleShape),
+                    .width(0.01.dp),
                 onFocusChange = { state ->
                     if ((!isPortrait) && state.isFocused) {
                         onTitleClick()

+ 38 - 0
tvlib/src/main/kotlin/org/mjdev/tvlib/ui/components/tv/NetworkErrorMessage.kt

@@ -0,0 +1,38 @@
+/*
+ *  Copyright (c) Milan Jurkulák 2023.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */
+
+package org.mjdev.tvlib.ui.components.tv
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.State
+import androidx.compose.runtime.collectAsState
+import androidx.compose.ui.graphics.Color
+import org.mjdev.tvlib.R
+import org.mjdev.tvlib.extensions.ComposeExt.asException
+import org.mjdev.tvlib.network.NetworkConnectivityService
+import org.mjdev.tvlib.network.NetworkConnectivityService.Companion.rememberNetworkService
+import org.mjdev.tvlib.network.NetworkStatus
+import org.mjdev.tvlib.network.isNotConnected
+
+@Composable
+fun NetworkErrorMessage(
+    message: Any? = R.string.error_no_network,
+    dismissible: Boolean = false,
+    backgroundColor: Color = Color.Black,
+    networkService: NetworkConnectivityService = rememberNetworkService(),
+    networkState: State<NetworkStatus?> = networkService.networkStatus
+        .collectAsState(NetworkStatus.Connected(null)),
+) {
+    if (networkState.isNotConnected) {
+        ErrorMessage(
+            error = message.asException(),
+            backgroundColor = backgroundColor,
+            dismissible = dismissible
+        )
+    }
+}

+ 1 - 1
tvlib/src/main/kotlin/org/mjdev/tvlib/ui/components/tv/ScrollableTvLazyRow.kt → tvlib/src/main/kotlin/org/mjdev/tvlib/ui/components/tv/ScrollableTvLazyColumn.kt

@@ -25,7 +25,7 @@ import org.mjdev.tvlib.ui.components.complex.VerticalScrollableBox
 
 @TvPreview
 @Composable
-fun ScrollableTvLazyRow(
+fun ScrollableTvLazyColumn(
     modifier: Modifier = Modifier,
     contentPadding: PaddingValues = PaddingValues(0.dp),
     reverseLayout: Boolean = false,

+ 20 - 0
tvlib/src/main/kotlin/org/mjdev/tvlib/updater/AppUpdateService.kt

@@ -0,0 +1,20 @@
+/*
+ *  Copyright (c) Milan Jurkulák 2023.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */
+
+package org.mjdev.tvlib.updater
+
+import com.skydoves.sandwich.ApiResponse
+import org.mjdev.tvlib.data.github.remote.Release
+import retrofit2.http.GET
+
+interface AppUpdateService {
+
+    @GET("releases")
+    suspend fun releases(): ApiResponse<List<Release>>
+
+}

+ 223 - 0
tvlib/src/main/kotlin/org/mjdev/tvlib/updater/AppUpdater.kt

@@ -0,0 +1,223 @@
+/*
+ *  Copyright (c) Milan Jurkulák 2023.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */
+
+package org.mjdev.tvlib.updater
+
+import android.content.ActivityNotFoundException
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import androidx.annotation.AnyThread
+import androidx.annotation.MainThread
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.platform.LocalContext
+import com.jakewharton.retrofit2.adapter.kotlin.coroutines.CoroutineCallAdapterFactory
+import com.skydoves.sandwich.adapters.ApiResponseCallAdapterFactory
+import com.skydoves.sandwich.getOrNull
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.runBlocking
+import okhttp3.OkHttpClient
+import okhttp3.logging.HttpLoggingInterceptor
+import org.mjdev.tvlib.data.github.remote.Release
+import org.mjdev.tvlib.extensions.ModifierExt.isDebug
+import org.mjdev.tvlib.network.CacheInterceptor
+import retrofit2.Retrofit
+import retrofit2.converter.moshi.MoshiConverterFactory
+import timber.log.Timber
+import java.util.Date
+
+// todo better logic
+class AppUpdater(
+    private val context: Context,
+    private val githubUser: String = "mimoccc",
+    private val githubRepository: String = "tvapp",
+) {
+
+    private val service by lazy {
+        updateService(githubUser, githubRepository)
+    }
+
+    private val prefs by lazy {
+        context.applicationContext.getSharedPreferences(
+            AppUpdater::class.simpleName,
+            Context.MODE_PRIVATE
+        )
+    }
+
+    private val lastReleaseTimestamp: Long get() = releases.filter { release ->
+        release.hasAPK
+    }.maxByOrNull { release ->
+        release.publishedAt.asTimestamp
+    }?.publishedAt?.asTimestamp ?: 0L
+
+    private val installedTimestamp = prefs.getLong(AppUpdater::class.simpleName, 0L)
+
+//    private val httpClient by lazy {
+//        okHttpClient()
+//    }
+
+//    private val externalFile: File
+//        get() = File(
+//            context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS),
+//            "$githubRepository-update.apk"
+//        )
+
+    private val releases: List<Release>
+        get() = runBlocking {
+            service.releases().getOrNull() ?: emptyList()
+        }
+
+    val isUpdateAvailable: Boolean get() = checkUpdate()
+
+    private fun checkUpdate(): Boolean {
+        return if (installedTimestamp == 0L) {
+            prefs.edit()
+                .putLong(AppUpdater::class.simpleName, lastReleaseTimestamp)
+                .apply()
+            false
+        } else {
+            true
+        }
+    }
+
+    @MainThread
+    fun updateApp() {
+        CoroutineScope(Dispatchers.IO).launch {
+            releases.filter { release ->
+                release.hasAPK
+            }.maxByOrNull { release ->
+                release.publishedAt.asTimestamp
+            }?.let { release ->
+                release.apkAsset?.downloadUrl
+            }?.also { url ->
+                downloadFile(url) {
+                    prefs.edit()
+                        .putLong(AppUpdater::class.simpleName, lastReleaseTimestamp)
+                        .apply()
+//                    CoroutineScope(Dispatchers.Main).launch {
+//                        startUpdate()
+//                    }
+                }
+            }
+        }
+    }
+
+    @AnyThread
+    private fun downloadFile(
+        url: String,
+        finalizeBlock: () -> Unit
+    ) {
+        // todo simply open download yet, until finished
+        try {
+            context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)).apply {
+                flags = Intent.FLAG_ACTIVITY_NEW_TASK
+            })
+            finalizeBlock()
+        } catch (e: ActivityNotFoundException) {
+            Timber.e(e)
+        }
+        // todo
+//        httpClient.download(url) { result ->
+//            result.onSuccess { data ->
+//                FileOutputStream(externalFile).apply {
+//                    write(data)
+//                    flush()
+//                    close()
+//                    finalizeBlock()
+//                }
+//            }.onFailure { e ->
+//                Timber.e(e)
+//            }
+//        }
+    }
+
+//    @AnyThread
+//    private suspend fun startUpdate() {
+//        val packageInstaller = PackageInstaller.getInstance(context)
+//        val apkUri = Uri.fromFile(externalFile)
+//        val fileName = externalFile.name
+//        try {
+//            when (val result = packageInstaller.createSession(apkUri) {
+//                confirmation = Confirmation.DEFERRED
+//                installerType = InstallerType.SESSION_BASED
+//                name = fileName
+//                requireUserAction = false
+//                notification {
+//                    title = NotificationString.resource(R.string.title_app_update)
+//                    contentText =
+//                        NotificationString.resource(R.string.text_update_message, fileName)
+//                    // todo icon =
+//                }
+//            }.await()) {
+//                is SessionResult.Success -> Timber.d("Success")
+//                is SessionResult.Error -> Timber.e(result.cause.message)
+//            }
+//        } catch (e: Exception) {
+//            Timber.e(e)
+//        }
+//    }
+
+    companion object {
+
+        private fun httpLoggingInterceptor() = HttpLoggingInterceptor().apply {
+            level = HttpLoggingInterceptor.Level.BODY
+        }
+
+        private fun okHttpClient(
+            cacheInterceptor: CacheInterceptor = CacheInterceptor(),
+            httpLoggingInterceptor: HttpLoggingInterceptor = httpLoggingInterceptor(),
+        ): OkHttpClient = OkHttpClient.Builder().apply {
+            addNetworkInterceptor(cacheInterceptor)
+            if (isDebug) {
+                addInterceptor(httpLoggingInterceptor)
+            }
+        }.build()
+
+        private fun retrofit(
+            okHttpClient: OkHttpClient,
+            baseUrl: String = "",
+        ): Retrofit = Retrofit.Builder()
+            .baseUrl(baseUrl)
+            .client(okHttpClient)
+            .addConverterFactory(MoshiConverterFactory.create())
+            .addCallAdapterFactory(ApiResponseCallAdapterFactory.create())
+            .addCallAdapterFactory(CoroutineCallAdapterFactory())
+            .build()
+
+        fun updateService(
+            githubUser: String = "mimoccc",
+            githubRepository: String = "tvapp",
+            baseUrl: String = "https://api.github.com/repos/$githubUser/$githubRepository/",
+            retrofit: Retrofit = retrofit(okHttpClient(), baseUrl)
+        ): AppUpdateService = retrofit.create(AppUpdateService::class.java)
+
+        @Composable
+        fun rememberAppUpdater(
+            githubUser: String = "mimoccc",
+            githubRepository: String = "tvapp",
+        ): AppUpdater {
+            val context = LocalContext.current
+            return remember(Unit) {
+                AppUpdater(context, githubUser, githubRepository)
+            }
+        }
+
+        @Suppress("DEPRECATION")
+        private val String.asTimestamp: Long
+            get() = try {
+                Date.parse(this)
+            } catch (e: Exception) {
+                Timber.e(e)
+                0L
+            }
+    }
+
+}

+ 3 - 0
tvlib/src/main/res/values/strings.xml

@@ -11,6 +11,7 @@
     <string name="app_name">Tv App</string>
 
     <string name="bt_dismiss">Dismiss</string>
+    <string name="bt_update">Update</string>
 
     <string name="error_no_network">Internet connection is missing.</string>
     <string name="error_unknown">Unknown error.</string>
@@ -27,9 +28,11 @@
     <string name="text_app_description">\n\n\nSimple TV Application for AndroidTV or SetTopBox with Android or any tablet or mobile device.\nAll in one solution for mobile app responsive to device.\nThis app is just an simple demonstration of Jetpack Compose and all other modern architecture components.\n\n\n</string>
     <string name="text_unimplemented_yet">This feature is not yet implemented.</string>
     <string name="text_author">Created by Milan Jurkulak © 2023</string>
+    <string name="text_update_message">Update ready to install.</string>
 
     <string name="title_about">About</string>
     <string name="title_apps">Applications</string>
+    <string name="title_app_update">Update</string>
     <string name="title_error">Error</string>
     <string name="title_home">Home</string>
     <string name="title_movie_detail">Movie detail</string>