Milan Jurkulak 2 anni fa
parent
commit
aa6f4a281e
100 ha cambiato i file con 4127 aggiunte e 960 eliminazioni
  1. 2 2
      .github/workflows/build-gradle-project.yml
  2. 24 0
      .run/tvapp [dependencyUpdatesTask].run.xml
  3. 24 0
      .run/tvapp [inAppInfoCreateDebug].run.xml
  4. 24 0
      .run/tvapp [inAppInfoCreateTaskDebug].run.xml
  5. 24 0
      .run/tvapp [launcherIconDebug].run.xml
  6. 24 0
      .run/tvapp [testCaseGenerator].run.xml
  7. 0 21
      SECURITY.md
  8. 25 168
      app/build.gradle.kts
  9. 149 83
      app/objectbox-models/default.json
  10. 22 8
      app/objectbox-models/default.json.bak
  11. 37 10
      app/src/main/AndroidManifest.xml
  12. 16 1
      app/src/main/assets/adblock.txt
  13. 12 2
      app/src/main/kotlin/org/mjdev/tvapp/activity/IPTVActivity.kt
  14. 1 0
      app/src/main/kotlin/org/mjdev/tvapp/activity/MainActivity.kt
  15. 18 10
      app/src/main/kotlin/org/mjdev/tvapp/activity/WebActivity.kt
  16. 12 5
      app/src/main/kotlin/org/mjdev/tvapp/app/Application.kt
  17. 86 0
      app/src/main/kotlin/org/mjdev/tvapp/cast/CastOptionsProvider.kt
  18. 222 0
      app/src/main/kotlin/org/mjdev/tvapp/cast/CastService.kt
  19. 41 0
      app/src/main/kotlin/org/mjdev/tvapp/cast/CastSessionTransferCallback.kt
  20. 13 0
      app/src/main/kotlin/org/mjdev/tvapp/data/events/PauseEvent.kt
  21. 13 0
      app/src/main/kotlin/org/mjdev/tvapp/data/events/SyncEnded.kt
  22. 16 0
      app/src/main/kotlin/org/mjdev/tvapp/data/events/SyncEvent.kt
  23. 13 0
      app/src/main/kotlin/org/mjdev/tvapp/data/events/SyncStarted.kt
  24. 11 2
      app/src/main/kotlin/org/mjdev/tvapp/data/local/Media.kt
  25. 37 0
      app/src/main/kotlin/org/mjdev/tvapp/data/local/ParsedLink.kt
  26. 1 1
      app/src/main/kotlin/org/mjdev/tvapp/data/local/TVChannel.kt
  27. 2 1
      app/src/main/kotlin/org/mjdev/tvapp/data/remote/Epg.kt
  28. 2 1
      app/src/main/kotlin/org/mjdev/tvapp/data/remote/Stream.kt
  29. 13 4
      app/src/main/kotlin/org/mjdev/tvapp/database/DAO.kt
  30. 10 11
      app/src/main/kotlin/org/mjdev/tvapp/module/ProvideModule.kt
  31. 58 12
      app/src/main/kotlin/org/mjdev/tvapp/sync/SyncAdapter.kt
  32. 15 1
      app/src/main/kotlin/org/mjdev/tvapp/sync/SyncService.kt
  33. 22 0
      app/src/main/kotlin/org/mjdev/tvapp/sync/SyncStarter.kt
  34. 9 7
      app/src/main/kotlin/org/mjdev/tvapp/sync/base/SyncItems.kt
  35. 54 2
      app/src/main/kotlin/org/mjdev/tvapp/sync/base/Syncer.kt
  36. 2 2
      app/src/main/kotlin/org/mjdev/tvapp/sync/provider/DataProvider.kt
  37. 14 5
      app/src/main/kotlin/org/mjdev/tvapp/sync/syncers/GithubMoviesSyncer.kt
  38. 0 42
      app/src/main/kotlin/org/mjdev/tvapp/sync/syncers/WebMovieSyncer.kt
  39. 3 3
      app/src/main/kotlin/org/mjdev/tvapp/ui/pages/AboutPage.kt
  40. 59 34
      app/src/main/kotlin/org/mjdev/tvapp/ui/pages/MainPage.kt
  41. 2 2
      app/src/main/kotlin/org/mjdev/tvapp/ui/pages/PluginsPage.kt
  42. 2 2
      app/src/main/kotlin/org/mjdev/tvapp/ui/pages/SubscriptionPage.kt
  43. 3 0
      app/src/main/kotlin/org/mjdev/tvapp/ui/screens/GalleryScreen.kt
  44. 22 8
      app/src/main/kotlin/org/mjdev/tvapp/ui/screens/IPTVScreen.kt
  45. 3 12
      app/src/main/kotlin/org/mjdev/tvapp/ui/screens/LoadingScreen.kt
  46. 1 0
      app/src/main/kotlin/org/mjdev/tvapp/ui/screens/MainScreen.kt
  47. 12 13
      app/src/main/kotlin/org/mjdev/tvapp/ui/screens/SplashScreen.kt
  48. 12 23
      app/src/main/kotlin/org/mjdev/tvapp/ui/screens/WebScreen.kt
  49. 2 2
      app/src/main/kotlin/org/mjdev/tvapp/viewmodel/DetailViewModel.kt
  50. 3 3
      app/src/main/kotlin/org/mjdev/tvapp/viewmodel/IPTVViewModel.kt
  51. 42 46
      app/src/main/kotlin/org/mjdev/tvapp/viewmodel/MainViewModel.kt
  52. 1 0
      app/src/main/kotlin/org/mjdev/tvapp/widget/MainAppWidget.kt
  53. 2 2
      app/src/main/kotlin/org/mjdev/tvapp/widget/MyAppWidgetReceiver.kt
  54. 2 2
      app/src/main/kotlin/org/mjdev/tvapp/widget/PlayPauseAction.kt
  55. 2 2
      app/src/main/kotlin/org/mjdev/tvapp/widget/RefreshAction.kt
  56. BIN
      app/src/main/res/drawable/background.png
  57. 22 14
      build.gradle.kts
  58. 34 31
      buildSrc/build.gradle.kts
  59. 34 0
      buildSrc/src/main/kotlin/org/mjdev/gradle/base/BaseTask.kt
  60. 48 0
      buildSrc/src/main/kotlin/org/mjdev/gradle/base/PluginBase.kt
  61. 11 0
      buildSrc/src/main/kotlin/org/mjdev/gradle/base/PluginExtension.kt
  62. 321 0
      buildSrc/src/main/kotlin/org/mjdev/gradle/base/PluginScope.kt
  63. 134 0
      buildSrc/src/main/kotlin/org/mjdev/gradle/base/TaskScope.kt
  64. 15 8
      buildSrc/src/main/kotlin/org/mjdev/gradle/dependency/DependencyHandler.kt
  65. 17 0
      buildSrc/src/main/kotlin/org/mjdev/gradle/extensions/DateExt.kt
  66. 20 0
      buildSrc/src/main/kotlin/org/mjdev/gradle/extensions/ExtFileFilter.kt
  67. 105 0
      buildSrc/src/main/kotlin/org/mjdev/gradle/extensions/File.kt
  68. 18 0
      buildSrc/src/main/kotlin/org/mjdev/gradle/extensions/HashMapExt.kt
  69. 7 0
      buildSrc/src/main/kotlin/org/mjdev/gradle/extensions/KotlinDynamicCompiler.kt
  70. 87 0
      buildSrc/src/main/kotlin/org/mjdev/gradle/extensions/KotlinPoetExt.kt
  71. 8 2
      buildSrc/src/main/kotlin/org/mjdev/gradle/extensions/PluginDependenciesSpecScope.kt
  72. 14 0
      buildSrc/src/main/kotlin/org/mjdev/gradle/extensions/PluginManager.kt
  73. 175 74
      buildSrc/src/main/kotlin/org/mjdev/gradle/extensions/Project.kt
  74. 47 48
      buildSrc/src/main/kotlin/org/mjdev/gradle/extensions/SigningConfigs.kt
  75. 82 0
      buildSrc/src/main/kotlin/org/mjdev/gradle/extensions/String.kt
  76. 91 0
      buildSrc/src/main/kotlin/org/mjdev/gradle/extensions/SysUtils.kt
  77. 265 0
      buildSrc/src/main/kotlin/org/mjdev/gradle/extensions/TaskExt.kt
  78. 30 0
      buildSrc/src/main/kotlin/org/mjdev/gradle/icons/LauncherIcon.kt
  79. 150 0
      buildSrc/src/main/kotlin/org/mjdev/gradle/icons/LauncherIconConfig.kt
  80. 64 0
      buildSrc/src/main/kotlin/org/mjdev/gradle/icons/LauncherIconExtension.kt
  81. 112 0
      buildSrc/src/main/kotlin/org/mjdev/gradle/icons/LauncherIconPlugin.kt
  82. 95 0
      buildSrc/src/main/kotlin/org/mjdev/gradle/icons/LauncherIconTask.kt
  83. 145 0
      buildSrc/src/main/kotlin/org/mjdev/gradle/icons/filter/ColorRibbonFilter.kt
  84. 37 0
      buildSrc/src/main/kotlin/org/mjdev/gradle/icons/filter/GrayscaleFilter.kt
  85. 8 0
      buildSrc/src/main/kotlin/org/mjdev/gradle/icons/filter/LauncherIconFilter.kt
  86. 48 0
      buildSrc/src/main/kotlin/org/mjdev/gradle/icons/filter/OverlayFilter.kt
  87. 49 0
      buildSrc/src/main/kotlin/org/mjdev/gradle/icons/utils/Resources.kt
  88. 0 14
      buildSrc/src/main/kotlin/org/mjdev/gradle/libs/Libs.kt
  89. 293 0
      buildSrc/src/main/kotlin/org/mjdev/gradle/plugin/AppPlugin.kt
  90. 41 0
      buildSrc/src/main/kotlin/org/mjdev/gradle/plugin/AppPluginParams.kt
  91. 0 41
      buildSrc/src/main/kotlin/org/mjdev/gradle/plugin/DependencyUpdatePlugin.kt
  92. 23 0
      buildSrc/src/main/kotlin/org/mjdev/gradle/plugin/LibPlugin.kt
  93. 14 0
      buildSrc/src/main/kotlin/org/mjdev/gradle/plugin/LibPluginParams.kt
  94. 0 173
      buildSrc/src/main/kotlin/org/mjdev/gradle/plugin/MainAppPlugin.kt
  95. 35 0
      buildSrc/src/main/kotlin/org/mjdev/gradle/tasks/DependencyUpdateCleanTask.kt
  96. 34 0
      buildSrc/src/main/kotlin/org/mjdev/gradle/tasks/DependencyUpdateTask.kt
  97. 28 0
      buildSrc/src/main/kotlin/org/mjdev/gradle/tasks/DetektCleanTask.kt
  98. 45 0
      buildSrc/src/main/kotlin/org/mjdev/gradle/tasks/DetektCreateTask.kt
  99. 35 0
      buildSrc/src/main/kotlin/org/mjdev/gradle/tasks/DokkaCleanTask.kt
  100. 45 0
      buildSrc/src/main/kotlin/org/mjdev/gradle/tasks/DokkaCreateTask.kt

+ 2 - 2
.github/workflows/build-gradle-project.yml

@@ -64,12 +64,12 @@ jobs:
           allowUpdates: true
           token: ${{ secrets.TOKEN }}
           tag: release-${{ env.GITHUB_APP_VERSION_NAME }}
-          artifacts: './app/build/outputs/apk/release/app-release.apk, ./release-notes.txt'
+          artifacts: './app/build/outputs/apk/release/app-release.apk, ./documentation/release-notes.txt, ./release.zip'
 # update wiki pages
       - name: Update wiki pages
         id: update_wiki_pages
         uses: OrlovM/Wiki-Action@v1
         with:
-          path: 'wiki'
+          path: 'documentation'
           token: ${{ secrets.TOKEN }}
 # done

+ 24 - 0
.run/tvapp [dependencyUpdatesTask].run.xml

@@ -0,0 +1,24 @@
+<component name="ProjectRunConfigurationManager">
+  <configuration default="false" name="tvapp [dependencyUpdatesTask]" type="GradleRunConfiguration" factoryName="Gradle">
+    <ExternalSystemSettings>
+      <option name="executionName" />
+      <option name="externalProjectPath" value="$PROJECT_DIR$" />
+      <option name="externalSystemIdString" value="GRADLE" />
+      <option name="scriptParameters" value="" />
+      <option name="taskDescriptions">
+        <list />
+      </option>
+      <option name="taskNames">
+        <list>
+          <option value="dependencyUpdatesTask" />
+        </list>
+      </option>
+      <option name="vmOptions" />
+    </ExternalSystemSettings>
+    <ExternalSystemDebugServerProcess>true</ExternalSystemDebugServerProcess>
+    <ExternalSystemReattachDebugProcess>true</ExternalSystemReattachDebugProcess>
+    <DebugAllEnabled>false</DebugAllEnabled>
+    <ForceTestExec>false</ForceTestExec>
+    <method v="2" />
+  </configuration>
+</component>

+ 24 - 0
.run/tvapp [inAppInfoCreateDebug].run.xml

@@ -0,0 +1,24 @@
+<component name="ProjectRunConfigurationManager">
+  <configuration default="false" name="tvapp [inAppInfoCreateDebug]" type="GradleRunConfiguration" factoryName="Gradle">
+    <ExternalSystemSettings>
+      <option name="executionName" />
+      <option name="externalProjectPath" value="$PROJECT_DIR$" />
+      <option name="externalSystemIdString" value="GRADLE" />
+      <option name="scriptParameters" value="" />
+      <option name="taskDescriptions">
+        <list />
+      </option>
+      <option name="taskNames">
+        <list>
+          <option value="inAppInfoCreateDebug" />
+        </list>
+      </option>
+      <option name="vmOptions" />
+    </ExternalSystemSettings>
+    <ExternalSystemDebugServerProcess>true</ExternalSystemDebugServerProcess>
+    <ExternalSystemReattachDebugProcess>true</ExternalSystemReattachDebugProcess>
+    <DebugAllEnabled>false</DebugAllEnabled>
+    <ForceTestExec>false</ForceTestExec>
+    <method v="2" />
+  </configuration>
+</component>

+ 24 - 0
.run/tvapp [inAppInfoCreateTaskDebug].run.xml

@@ -0,0 +1,24 @@
+<component name="ProjectRunConfigurationManager">
+  <configuration default="false" name="tvapp [inAppInfoCreateTaskDebug]" type="GradleRunConfiguration" factoryName="Gradle">
+    <ExternalSystemSettings>
+      <option name="executionName" />
+      <option name="externalProjectPath" value="$PROJECT_DIR$" />
+      <option name="externalSystemIdString" value="GRADLE" />
+      <option name="scriptParameters" value="" />
+      <option name="taskDescriptions">
+        <list />
+      </option>
+      <option name="taskNames">
+        <list>
+          <option value="inAppInfoCreateTaskDebug" />
+        </list>
+      </option>
+      <option name="vmOptions" />
+    </ExternalSystemSettings>
+    <ExternalSystemDebugServerProcess>true</ExternalSystemDebugServerProcess>
+    <ExternalSystemReattachDebugProcess>true</ExternalSystemReattachDebugProcess>
+    <DebugAllEnabled>false</DebugAllEnabled>
+    <ForceTestExec>false</ForceTestExec>
+    <method v="2" />
+  </configuration>
+</component>

+ 24 - 0
.run/tvapp [launcherIconDebug].run.xml

@@ -0,0 +1,24 @@
+<component name="ProjectRunConfigurationManager">
+  <configuration default="false" name="tvapp [launcherIconDebug]" type="GradleRunConfiguration" factoryName="Gradle">
+    <ExternalSystemSettings>
+      <option name="executionName" />
+      <option name="externalProjectPath" value="$PROJECT_DIR$" />
+      <option name="externalSystemIdString" value="GRADLE" />
+      <option name="scriptParameters" value="" />
+      <option name="taskDescriptions">
+        <list />
+      </option>
+      <option name="taskNames">
+        <list>
+          <option value="launcherIconDebug" />
+        </list>
+      </option>
+      <option name="vmOptions" />
+    </ExternalSystemSettings>
+    <ExternalSystemDebugServerProcess>true</ExternalSystemDebugServerProcess>
+    <ExternalSystemReattachDebugProcess>true</ExternalSystemReattachDebugProcess>
+    <DebugAllEnabled>false</DebugAllEnabled>
+    <ForceTestExec>false</ForceTestExec>
+    <method v="2" />
+  </configuration>
+</component>

+ 24 - 0
.run/tvapp [testCaseGenerator].run.xml

@@ -0,0 +1,24 @@
+<component name="ProjectRunConfigurationManager">
+  <configuration default="false" name="tvapp [testCaseGenerator]" type="GradleRunConfiguration" factoryName="Gradle">
+    <ExternalSystemSettings>
+      <option name="executionName" />
+      <option name="externalProjectPath" value="$PROJECT_DIR$" />
+      <option name="externalSystemIdString" value="GRADLE" />
+      <option name="scriptParameters" value="" />
+      <option name="taskDescriptions">
+        <list />
+      </option>
+      <option name="taskNames">
+        <list>
+          <option value="testCaseGenerator" />
+        </list>
+      </option>
+      <option name="vmOptions" />
+    </ExternalSystemSettings>
+    <ExternalSystemDebugServerProcess>true</ExternalSystemDebugServerProcess>
+    <ExternalSystemReattachDebugProcess>true</ExternalSystemReattachDebugProcess>
+    <DebugAllEnabled>false</DebugAllEnabled>
+    <ForceTestExec>false</ForceTestExec>
+    <method v="2" />
+  </configuration>
+</component>

+ 0 - 21
SECURITY.md

@@ -1,21 +0,0 @@
-# Security Policy
-
-## Supported Versions
-
-Use this section to tell people about which versions of your project are
-currently being supported with security updates.
-
-| Version | Supported          |
-| ------- | ------------------ |
-| 5.1.x   | :white_check_mark: |
-| 5.0.x   | :x:                |
-| 4.0.x   | :white_check_mark: |
-| < 4.0   | :x:                |
-
-## Reporting a Vulnerability
-
-Use this section to tell people how to report a vulnerability.
-
-Tell them where to go, how often they can expect to get an update on a
-reported vulnerability, what to expect if the vulnerability is accepted or
-declined, etc.

+ 25 - 168
app/build.gradle.kts

@@ -1,113 +1,56 @@
 /*
- * Copyright (c) Milan Jurkulák 2023.
- * Contact:
- * e: mimoccc@gmail.com
- * e: mj@mjdev.org
- * w: https://mjdev.org
+ *  Copyright (c) Milan Jurkulák 2024.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
  */
 
-import org.mjdev.gradle.dependency.anotherDependencies
-import org.mjdev.gradle.dependency.baseDependencies
-import org.mjdev.gradle.dependency.composeDependencies
-import org.mjdev.gradle.dependency.daggerDependencies
-import org.mjdev.gradle.dependency.encryptDependencies
-import org.mjdev.gradle.dependency.exoPlayerDependencies
-import org.mjdev.gradle.dependency.glideDependencies
-import org.mjdev.gradle.dependency.mjdevTvLib
-import org.mjdev.gradle.dependency.moshiDependencies
-import org.mjdev.gradle.dependency.okHttpDependencies
-import org.mjdev.gradle.dependency.permissionsDependencies
-import org.mjdev.gradle.dependency.retrofitDependencies
-import org.mjdev.gradle.dependency.sandwichDependencies
-import org.mjdev.gradle.dependency.testDependencies
-import org.mjdev.gradle.dependency.timberDependencies
-import org.mjdev.gradle.dependency.widgetDependencies
 import org.mjdev.gradle.extensions.buildConfigString
-import org.mjdev.gradle.extensions.loadKeyStoreProperties
 import org.mjdev.gradle.extensions.manifestPlaceholders
 import org.mjdev.gradle.extensions.stringRes
 import org.mjdev.gradle.extensions.suffixToString
-import org.mjdev.gradle.extensions.versionCode
-import org.mjdev.gradle.extensions.versionName
-import org.mjdev.gradle.plugin.javaVersion
-import org.mjdev.gradle.plugin.kotlinCompilerExtVersion
-import org.mjdev.gradle.plugin.projectCompileSdk
-import org.mjdev.gradle.plugin.projectMinSdk
-
-@Suppress("PropertyName")
-val CONFIG_KEYSTORE_PROPERTIES_FILE = "config/keystore.properties"
-
-@Suppress("PropertyName")
-val SIGNING_CONFIG_NAME = "Any"
 
 plugins {
-//    kotlinAndroid()
-//    kotlinKapt()
-//    kotlinParcelize()
-//    androidApplication()
-//    hilt()
-//    ksp()
-//    mainAppPlugin()
-//    objectBox()
-    kotlin("android")
-    kotlin("kapt")
+    id("org.jetbrains.kotlin.android")
     id("com.android.application")
-    id("kotlin-parcelize")
-    id("com.google.dagger.hilt.android")
-    id("dagger.hilt.android.plugin")
-    id("com.google.devtools.ksp")
-//    id("com.google.android.libraries.mapsplatform.secrets-gradle-plugin")
-//    id ("app.cash.paparazzi") version "1.3.1"
-//    id("DependencyUpdatePlugin")
-//    id("AndroidCoreLibraryPlugin")
-    id("MainAppPlugin")
-    id("io.objectbox")
+    id("AppPlugin")
 }
 
-android {
-    namespace = "org.mjdev.tvapp"
-    compileSdk = projectCompileSdk
+appConfig {
+    packageName = "org.mjdev.tvapp"
 
-    signingConfigs {
-        loadKeyStoreProperties(
-            CONFIG_KEYSTORE_PROPERTIES_FILE
-        ) { aliasKey, passwordKey, fileStore, passwordStore ->
-            create(SIGNING_CONFIG_NAME) {
-                keyAlias = aliasKey
-                keyPassword = passwordKey
-                storeFile = fileStore
-                storePassword = passwordStore
-            }
-        }
-    }
+    minSdk = 21
+    compileSdk = 34
 
-    defaultConfig {
-        applicationId = "org.mjdev.tvapp"
+    autoCorrectCode = false
+    createDocumentation = false
+    createReleaseNotes = true
+    renameApkOutputByAppID = false
+    updateDependencies = false
+    createReleaseZip = true
+    createInfoClass = false
+    buildTypeInLauncherIcon = false
 
-        minSdk = projectMinSdk
-        targetSdk = projectCompileSdk
+    signingConfigName = "Any"
+    signingPropertiesFile = "config/keystore.properties"
 
-        versionCode = project.versionCode
-        versionName = project.versionName
-
-        signingConfig = signingConfigs[SIGNING_CONFIG_NAME]
+    versionPropertiesFile = "config/version.properties"
+}
 
+android {
+    defaultConfig {
         buildConfigString(
             "IPTV_API_URL" to "https://iptv-org.github.io/api/",
             "GITHUB_USER" to "mimoccc",
             "GITHUB_REPOSITORY" to "tvapp"
         )
-
-        multiDexEnabled = true
-
         manifestPlaceholders(
             "auth0Domain" to "@string/com_auth0_domain",
             "auth0Scheme" to "demo"
         )
     }
-
     buildTypes {
-
         debug {
             applicationIdSuffix = ".debug"
             isDebuggable = true
@@ -115,10 +58,6 @@ android {
             isShrinkResources = false
             isCrunchPngs = false
             isEmbedMicroApp = true
-            proguardFiles(
-                getDefaultProguardFile("proguard-android-optimize.txt"),
-                "proguard-rules.pro"
-            )
             buildConfigString(
                 "SYNC_AUTH" to "${defaultConfig.applicationId}$applicationIdSuffix.sync"
             )
@@ -127,7 +66,6 @@ android {
                 "app_name" to "TV App ${applicationIdSuffix.suffixToString()}"
             )
         }
-
         release {
             applicationIdSuffix = ""
             isDebuggable = false
@@ -135,10 +73,6 @@ android {
             isShrinkResources = true
             isCrunchPngs = true
             isEmbedMicroApp = true
-            proguardFiles(
-                getDefaultProguardFile("proguard-android-optimize.txt"),
-                "proguard-rules.pro"
-            )
             buildConfigString(
                 "SYNC_AUTH" to "${defaultConfig.applicationId}$applicationIdSuffix.sync"
             )
@@ -148,81 +82,4 @@ android {
             )
         }
     }
-
-    buildFeatures {
-        compose = true
-        buildConfig = true
-    }
-
-    compileOptions {
-        sourceCompatibility = javaVersion
-        targetCompatibility = javaVersion
-    }
-
-    kotlinOptions {
-        jvmTarget = javaVersion.toString()
-    }
-
-    composeOptions {
-        kotlinCompilerExtensionVersion = kotlinCompilerExtVersion
-    }
-
-    packaging {
-        resources.excludes.apply {
-            add("/META-INF/{AL2.0,LGPL2.1}")
-            add("/META-INF/DEPENDENCIES")
-            add("/mozilla/public-suffix-list.txt")
-        }
-    }
-
-    lint {
-        checkReleaseBuilds = false
-    }
-
-    kapt {
-        correctErrorTypes = true
-    }
-
-    hilt {
-        enableAggregatingTask = true
-    }
-
-//    applicationVariants.all {
-//        outputs.map {
-//            it as BaseVariantOutputImpl
-//        }.forEach { output ->
-//            val outputFileName = "$applicationId-$versionName.apk"
-//            output.outputFileName = outputFileName
-//        }
-//    }
-
-    tasks {
-        dokkaGfm {
-            outputDirectory.set(File(projectDir, "../wiki/documentation/"))
-        }
-    }
-
-    mainAppConfig {
-        createDocumentation = true
-    }
-}
-
-dependencies {
-    mjdevTvLib()
-    baseDependencies()
-    composeDependencies()
-    daggerDependencies()
-    moshiDependencies()
-    okHttpDependencies()
-    timberDependencies()
-    retrofitDependencies()
-    sandwichDependencies()
-    glideDependencies()
-//    imageIODependencies()
-    exoPlayerDependencies()
-    encryptDependencies()
-    permissionsDependencies()
-    anotherDependencies()
-    widgetDependencies()
-    testDependencies()
 }

+ 149 - 83
app/objectbox-models/default.json

@@ -37,84 +37,6 @@
       ],
       "relations": []
     },
-    {
-      "id": "2:7141750768191734097",
-      "lastPropertyId": "13:503445141634537419",
-      "name": "Movie",
-      "properties": [
-        {
-          "id": "2:4325777566417003871",
-          "name": "category",
-          "indexId": "7:4917936932975926084",
-          "type": 9,
-          "flags": 2048
-        },
-        {
-          "id": "3:3140383628530752513",
-          "name": "studio",
-          "indexId": "1:7948152039652986882",
-          "type": 9,
-          "flags": 2048
-        },
-        {
-          "id": "4:7177523158231354514",
-          "name": "title",
-          "indexId": "3:4293154197130924572",
-          "type": 9,
-          "flags": 2048
-        },
-        {
-          "id": "5:6928102760022948176",
-          "name": "description",
-          "indexId": "4:647869640726053234",
-          "type": 9,
-          "flags": 2048
-        },
-        {
-          "id": "6:3074162147955651553",
-          "name": "background",
-          "type": 9
-        },
-        {
-          "id": "7:1404798181518812606",
-          "name": "image",
-          "type": 9
-        },
-        {
-          "id": "8:6917239407098203120",
-          "name": "uri",
-          "indexId": "5:2693739985883588422",
-          "type": 9,
-          "flags": 2080
-        },
-        {
-          "id": "10:3350489478053652777",
-          "name": "country",
-          "indexId": "2:2822558398014900887",
-          "type": 9,
-          "flags": 2048
-        },
-        {
-          "id": "11:3647500054699987607",
-          "name": "isNsfw",
-          "type": 1
-        },
-        {
-          "id": "12:6138526348322531011",
-          "name": "id",
-          "type": 6,
-          "flags": 1
-        },
-        {
-          "id": "13:503445141634537419",
-          "name": "subtitle",
-          "indexId": "6:8857753723584819000",
-          "type": 9,
-          "flags": 2048
-        }
-      ],
-      "relations": []
-    },
     {
       "id": "3:9057081142853951475",
       "lastPropertyId": "21:621330748111634389",
@@ -291,18 +213,151 @@
         }
       ],
       "relations": []
+    },
+    {
+      "id": "7:2145814282594875835",
+      "lastPropertyId": "13:3197789274153187964",
+      "name": "Media",
+      "properties": [
+        {
+          "id": "1:2493977002790726231",
+          "name": "id",
+          "type": 6,
+          "flags": 1
+        },
+        {
+          "id": "2:1003960620091696722",
+          "name": "studio",
+          "indexId": "8:8281982468062206521",
+          "type": 9,
+          "flags": 2048
+        },
+        {
+          "id": "3:3361165184437182919",
+          "name": "country",
+          "indexId": "9:411083825822623257",
+          "type": 9,
+          "flags": 2048
+        },
+        {
+          "id": "4:3417571720995057675",
+          "name": "isNsfw",
+          "type": 1
+        },
+        {
+          "id": "5:6680399264979926378",
+          "name": "mimeType",
+          "type": 9
+        },
+        {
+          "id": "6:323279769700970241",
+          "name": "webUrl",
+          "indexId": "10:1405485025918246156",
+          "type": 9,
+          "flags": 2048
+        },
+        {
+          "id": "7:8683972584879126535",
+          "name": "title",
+          "indexId": "11:1958456815235591691",
+          "type": 9,
+          "flags": 2048
+        },
+        {
+          "id": "8:5659998378539259537",
+          "name": "description",
+          "indexId": "12:8096855908627690948",
+          "type": 9,
+          "flags": 2048
+        },
+        {
+          "id": "9:4329975379009996201",
+          "name": "uri",
+          "indexId": "13:4107808404859088732",
+          "type": 9,
+          "flags": 2080
+        },
+        {
+          "id": "10:295441504080886574",
+          "name": "subtitle",
+          "indexId": "14:5257063439798602682",
+          "type": 9,
+          "flags": 2048
+        },
+        {
+          "id": "11:2489852158161407521",
+          "name": "category",
+          "indexId": "15:6718017885537342229",
+          "type": 9,
+          "flags": 2048
+        },
+        {
+          "id": "12:5995287701291939533",
+          "name": "background",
+          "type": 9
+        },
+        {
+          "id": "13:3197789274153187964",
+          "name": "image",
+          "type": 9
+        }
+      ],
+      "relations": []
+    },
+    {
+      "id": "8:4975625744780629577",
+      "lastPropertyId": "5:5646686391764560644",
+      "name": "ParsedLink",
+      "properties": [
+        {
+          "id": "1:7914697169572561467",
+          "name": "id",
+          "type": 6,
+          "flags": 1
+        },
+        {
+          "id": "2:2515908858477189908",
+          "name": "title",
+          "type": 9
+        },
+        {
+          "id": "3:4797269088747962059",
+          "name": "url",
+          "type": 9
+        },
+        {
+          "id": "4:2941706487877269236",
+          "name": "thumbUrl",
+          "type": 9
+        },
+        {
+          "id": "5:5646686391764560644",
+          "name": "parsed",
+          "type": 6
+        }
+      ],
+      "relations": []
     }
   ],
-  "lastEntityId": "6:8612359832519392835",
-  "lastIndexId": "7:4917936932975926084",
+  "lastEntityId": "8:4975625744780629577",
+  "lastIndexId": "15:6718017885537342229",
   "lastRelationId": "0:0",
   "lastSequenceId": "0:0",
   "modelVersion": 5,
   "modelVersionParserMinimum": 5,
   "retiredEntityUids": [
-    1987880888030669889
+    1987880888030669889,
+    7141750768191734097
+  ],
+  "retiredIndexUids": [
+    4917936932975926084,
+    7948152039652986882,
+    4293154197130924572,
+    647869640726053234,
+    2693739985883588422,
+    2822558398014900887,
+    8857753723584819000
   ],
-  "retiredIndexUids": [],
   "retiredPropertyUids": [
     2336450843545835846,
     1211648868008137353,
@@ -319,7 +374,18 @@
     5012672523655215079,
     7864280554037368021,
     3813998053361806387,
-    6587709986428549544
+    6587709986428549544,
+    4325777566417003871,
+    3140383628530752513,
+    7177523158231354514,
+    6928102760022948176,
+    3074162147955651553,
+    1404798181518812606,
+    6917239407098203120,
+    3350489478053652777,
+    3647500054699987607,
+    6138526348322531011,
+    503445141634537419
   ],
   "retiredRelationUids": [],
   "version": 1

+ 22 - 8
app/objectbox-models/default.json.bak

@@ -45,22 +45,30 @@
         {
           "id": "2:4325777566417003871",
           "name": "category",
-          "type": 9
+          "indexId": "7:4917936932975926084",
+          "type": 9,
+          "flags": 2048
         },
         {
           "id": "3:3140383628530752513",
           "name": "studio",
-          "type": 9
+          "indexId": "1:7948152039652986882",
+          "type": 9,
+          "flags": 2048
         },
         {
           "id": "4:7177523158231354514",
           "name": "title",
-          "type": 9
+          "indexId": "3:4293154197130924572",
+          "type": 9,
+          "flags": 2048
         },
         {
           "id": "5:6928102760022948176",
           "name": "description",
-          "type": 9
+          "indexId": "4:647869640726053234",
+          "type": 9,
+          "flags": 2048
         },
         {
           "id": "6:3074162147955651553",
@@ -75,12 +83,16 @@
         {
           "id": "8:6917239407098203120",
           "name": "uri",
-          "type": 9
+          "indexId": "5:2693739985883588422",
+          "type": 9,
+          "flags": 2080
         },
         {
           "id": "10:3350489478053652777",
           "name": "country",
-          "type": 9
+          "indexId": "2:2822558398014900887",
+          "type": 9,
+          "flags": 2048
         },
         {
           "id": "11:3647500054699987607",
@@ -96,7 +108,9 @@
         {
           "id": "13:503445141634537419",
           "name": "subtitle",
-          "type": 9
+          "indexId": "6:8857753723584819000",
+          "type": 9,
+          "flags": 2048
         }
       ],
       "relations": []
@@ -280,7 +294,7 @@
     }
   ],
   "lastEntityId": "6:8612359832519392835",
-  "lastIndexId": "0:0",
+  "lastIndexId": "7:4917936932975926084",
   "lastRelationId": "0:0",
   "lastSequenceId": "0:0",
   "modelVersion": 5,

+ 37 - 10
app/src/main/AndroidManifest.xml

@@ -17,18 +17,24 @@
     <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
     <uses-permission android:name="android.permission.READ_SYNC_SETTINGS" />
     <uses-permission android:name="android.permission.WRITE_SYNC_SETTINGS" />
+
+<!--    <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />-->
     <uses-permission
         android:name="android.permission.READ_EXTERNAL_STORAGE"
         tools:ignore="ScopedStorage" />
+
     <uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
     <uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
     <uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
-    <uses-permission
-        android:name="android.permission.MANAGE_EXTERNAL_STORAGE"
-        tools:ignore="ScopedStorage" />
+
     <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
+    <uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
+    <uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
     <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
+    <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />
+
     <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
+
     <uses-permission
         android:name="android.permission.AUTHENTICATE_ACCOUNTS"
         android:maxSdkVersion="22" />
@@ -61,6 +67,10 @@
             android:name="com.google.android.gms.wallet.api.enabled"
             android:value="true" />
 
+        <meta-data
+            android:name="com.google.android.gms.cast.framework.OPTIONS_PROVIDER_CLASS_NAME"
+            android:value="org.mjdev.tvapp.cast.CastOptionsProvider" />
+
         <activity
             android:name=".activity.MainActivity"
             android:clearTaskOnLaunch="true"
@@ -68,9 +78,9 @@
             android:exported="true"
             android:finishOnTaskLaunch="true"
             android:launchMode="singleTask"
-            android:windowLayoutInDisplayCutoutMode="default"
             android:screenOrientation="fullSensor"
-            android:theme="@style/Theme.TvApp">
+            android:theme="@style/Theme.TvApp"
+            android:windowLayoutInDisplayCutoutMode="default">
 
             <intent-filter>
                 <action android:name="android.intent.action.MAIN" />
@@ -104,9 +114,9 @@
             android:finishOnTaskLaunch="true"
             android:label="@string/title_iptv"
             android:launchMode="singleTask"
-            android:windowLayoutInDisplayCutoutMode="default"
             android:screenOrientation="fullSensor"
-            android:theme="@style/Theme.TvApp">
+            android:theme="@style/Theme.TvApp"
+            android:windowLayoutInDisplayCutoutMode="default">
 
             <!--            <intent-filter>-->
             <!--                <action android:name="android.intent.action.MAIN" />-->
@@ -128,11 +138,11 @@
             android:configChanges="orientation|screenSize|screenLayout|keyboardHidden"
             android:exported="true"
             android:finishOnTaskLaunch="true"
-            android:windowLayoutInDisplayCutoutMode="default"
             android:label="@string/title_web"
             android:launchMode="singleTask"
             android:screenOrientation="fullSensor"
-            android:theme="@style/Theme.TvApp">
+            android:theme="@style/Theme.TvApp"
+            android:windowLayoutInDisplayCutoutMode="default">
 
             <intent-filter>
                 <action android:name="android.intent.action.MAIN" />
@@ -141,7 +151,7 @@
                 <category android:name="android.intent.category.DEFAULT" />
             </intent-filter>
 
-            <!--ebug-->
+            <!--Debug-->
 
             <intent-filter>
                 <action android:name="android.intent.action.MAIN" />
@@ -209,6 +219,10 @@
 
         </service>
 
+        <service
+            android:name=".cast.CastService"
+            android:exported="false" />
+
         <provider
             android:name=".sync.provider.DataProvider"
             android:authorities="@string/sync_auth"
@@ -217,6 +231,14 @@
             android:syncable="true"
             tools:ignore="ExportedContentProvider" />
 
+        <receiver
+            android:name=".sync.SyncStarter"
+            android:exported="true">
+            <intent-filter>
+                <action android:name="android.intent.action.BOOT_COMPLETED" />
+            </intent-filter>
+        </receiver>
+
         <receiver
             android:name=".widget.MyAppWidgetReceiver"
             android:exported="true">
@@ -231,6 +253,11 @@
 
         </receiver>
 
+        <receiver
+            android:name="androidx.mediarouter.media.MediaTransferReceiver"
+            android:exported="true"
+            tools:ignore="ExportedReceiver" />
+
     </application>
 
 </manifest>

+ 16 - 1
app/src/main/assets/adblock.txt

@@ -75,6 +75,11 @@
 *a.bestcontentcost.top
 *b.bestcontentcost.top
 *bongacams.com
+*fonts.gstatic.com
+*sharethis.com
+*platform-api.sharethis.com
+*blg-1216lb.com
+*blg-12161b.com
 ####################################################################################################
 # URLS #############################################################################################
 ####################################################################################################
@@ -114,4 +119,14 @@ https://stream.highwebmedia.com/
 https://zbporn.com/search_videos_json.php
 https://www.sexvid.xxx/search_videos_json.php
 https://www.rat.xxx/search_videos_json.php
-https://www.pornid.xxx/search_videos_json.php
+https://www.pornid.xxx/search_videos_json.php
+https://www.gstatic.com/recaptcha/
+https://www.google.com/recaptcha/
+https://prehraj.to/front/video-js/plugins/videojs-ima/
+https://prehraj.to/front/video-js/plugins/videojs-contrib-ads/
+https://prehraj.to/front/js/alt-ads.js
+https://prehraj.to/front/img/favicon/favicon.png
+https://prehraj.to/front/img/favicon/favicon.gif
+https://prehraj.to/front/img/favicon/favicon.jpg
+https://securepubads.g.doubleclick.net/
+

+ 12 - 2
app/src/main/kotlin/org/mjdev/tvapp/activity/IPTVActivity.kt

@@ -9,9 +9,11 @@
 package org.mjdev.tvapp.activity
 
 import android.content.Intent
+import android.net.Uri
 import androidx.compose.runtime.Composable
+import androidx.compose.ui.graphics.Color
 import dagger.hilt.android.AndroidEntryPoint
-import org.mjdev.tvapp.data.local.Movie
+import org.mjdev.tvapp.data.local.Media
 import org.mjdev.tvapp.ui.screens.GalleryScreen
 import org.mjdev.tvlib.extensions.NavGraphBuilderExt.screen
 import org.mjdev.tvlib.navigation.NavGraphBuilderEx
@@ -25,10 +27,14 @@ import org.mjdev.tvlib.interfaces.ItemAudio
 import org.mjdev.tvlib.interfaces.ItemPhoto
 import org.mjdev.tvlib.interfaces.ItemVideo
 import org.mjdev.tvlib.navigation.NavHostControllerEx
+import java.net.URL
 
+@Suppress("PreviewShouldNotBeCalledRecursively")
 @AndroidEntryPoint
 class IPTVActivity : TvActivity() {
 
+    override val backgroundColor: Color = Color.Black
+
     @Previews
     @Composable
     override fun Compose() = super.Compose()
@@ -44,10 +50,14 @@ class IPTVActivity : TvActivity() {
         val data = intent?.getSerializableExtra(IPTV_DATA)
         when (data) {
             null -> finish()
-            is Movie -> navController.openAsTop<IPTVScreen>(data)
+            is Media -> navController.openAsTop<IPTVScreen>(data)
             is ItemAudio -> navController.openAsTop<IPTVScreen>(data)
             is ItemVideo -> navController.openAsTop<IPTVScreen>(data)
             is ItemPhoto -> navController.openAsTop<GalleryScreen>(data)
+            is Uri -> navController.openAsTop<IPTVScreen>(data)
+            is URL -> navController.openAsTop<IPTVScreen>(data)
+            // todo may be web?
+            is String -> navController.openAsTop<IPTVScreen>(data)
             else -> navController.openAsTop<GalleryScreen>(data)
         }
     }

+ 1 - 0
app/src/main/kotlin/org/mjdev/tvapp/activity/MainActivity.kt

@@ -18,6 +18,7 @@ import org.mjdev.tvlib.annotations.Previews
 import org.mjdev.tvlib.extensions.NavGraphBuilderExt.homeScreen
 import org.mjdev.tvlib.extensions.NavGraphBuilderExt.startScreen
 
+@Suppress("PreviewShouldNotBeCalledRecursively")
 @AndroidEntryPoint
 class MainActivity : TvActivity() {
 

+ 18 - 10
app/src/main/kotlin/org/mjdev/tvapp/activity/WebActivity.kt

@@ -1,29 +1,31 @@
 /*
- * Copyright (c) Milan Jurkulák 2023.
- * Contact:
- * e: mimoccc@gmail.com
- * e: mj@mjdev.org
- * w: https://mjdev.org
+ *  Copyright (c) Milan Jurkulák 2024.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
  */
 
 package org.mjdev.tvapp.activity
 
 import android.content.Intent
-import android.net.Uri
 import androidx.compose.runtime.Composable
+import androidx.compose.ui.graphics.Color
 import dagger.hilt.android.AndroidEntryPoint
-import org.mjdev.tvlib.activity.ComposableActivity
-import org.mjdev.tvlib.extensions.NavGraphBuilderExt.screen
 import org.mjdev.tvlib.navigation.NavGraphBuilderEx
 import org.mjdev.tvapp.ui.screens.WebScreen
+import org.mjdev.tvlib.activity.TvActivity
 import org.mjdev.tvlib.annotations.Previews
 import org.mjdev.tvlib.extensions.NavControllerExt.openAsTop
 import org.mjdev.tvlib.extensions.NavGraphBuilderExt.startScreen
 import org.mjdev.tvlib.extensions.StringExt.parseUri
 import org.mjdev.tvlib.navigation.NavHostControllerEx
 
+@Suppress("PreviewShouldNotBeCalledRecursively")
 @AndroidEntryPoint
-class WebActivity : ComposableActivity() {
+class WebActivity : TvActivity() {
+
+    override val backgroundColor: Color = Color.Black
 
     @Previews
     @Composable
@@ -34,7 +36,13 @@ class WebActivity : ComposableActivity() {
     }
 
     override fun onIntent(navController: NavHostControllerEx, intent: Intent?) {
-        val data : Uri = intent?.data ?: "about:blank".parseUri()
+        val data: Any? = when (intent?.action) {
+            Intent.ACTION_VIEW -> intent.data ?: "about:blank".parseUri()
+            Intent.ACTION_SEND -> intent.clipData.let {
+                it?.getItemAt(0)
+            }?.text
+            else -> null
+        }
         navController.openAsTop<WebScreen>(data)
     }
 

+ 12 - 5
app/src/main/kotlin/org/mjdev/tvapp/app/Application.kt

@@ -1,17 +1,20 @@
 /*
- * Copyright (c) Milan Jurkulák 2023.
- * Contact:
- * e: mimoccc@gmail.com
- * e: mj@mjdev.org
- * w: https://mjdev.org
+ *  Copyright (c) Milan Jurkulák 2024.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
  */
 
 package org.mjdev.tvapp.app
 
 import dagger.hilt.android.HiltAndroidApp
+import org.mjdev.tvapp.BuildConfig
+import org.mjdev.tvapp.cast.CastService
 import org.mjdev.tvapp.sync.SyncService.Companion.createSyncAccount
 import org.mjdev.tvapp.sync.SyncService.Companion.requestSync
 import org.mjdev.tvlib.application.TvApplication
+import timber.log.Timber
 
 @HiltAndroidApp
 class Application : TvApplication() {
@@ -24,7 +27,11 @@ class Application : TvApplication() {
 
     override fun onCreate() {
         super.onCreate()
+        if (BuildConfig.DEBUG) {
+            Timber.plant(Timber.DebugTree())
+        }
         createAccountAndSync()
+        CastService.start(this)
     }
 
 }

+ 86 - 0
app/src/main/kotlin/org/mjdev/tvapp/cast/CastOptionsProvider.kt

@@ -0,0 +1,86 @@
+/*
+ *  Copyright (c) Milan Jurkulák 2024.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */
+
+package org.mjdev.tvapp.cast
+
+import android.content.Context
+import androidx.annotation.Keep
+import com.google.android.gms.cast.CastMediaControlIntent
+import com.google.android.gms.cast.framework.CastOptions
+import com.google.android.gms.cast.framework.OptionsProvider
+import com.google.android.gms.cast.framework.SessionProvider
+import com.google.android.gms.cast.framework.media.CastMediaOptions
+import com.google.android.gms.cast.framework.media.ImageHints
+import com.google.android.gms.cast.framework.media.ImagePicker
+import com.google.android.gms.cast.framework.media.MediaIntentReceiver
+import com.google.android.gms.cast.framework.media.NotificationOptions
+import com.google.android.gms.common.images.WebImage
+import org.mjdev.tvapp.activity.MainActivity
+import com.google.android.gms.cast.MediaMetadata
+
+@Suppress("unused")
+@Keep
+class CastOptionsProvider : OptionsProvider {
+
+    private val notificationOptions by lazy {
+        NotificationOptions.Builder()
+            .setActions(
+                listOf(
+                    MediaIntentReceiver.ACTION_FORWARD,
+                    MediaIntentReceiver.ACTION_TOGGLE_PLAYBACK,
+                    MediaIntentReceiver.ACTION_STOP_CASTING
+                ),
+                intArrayOf(
+                    1,
+                    2
+                )
+            )
+            .setTargetActivityClassName(MainActivity::class.java.name)
+            .build()
+    }
+
+    private val mediaOptions by lazy {
+        CastMediaOptions.Builder()
+            .setImagePicker(ImagePickerImpl())
+            .setNotificationOptions(notificationOptions)
+            .build()
+    }
+
+    override fun getCastOptions(
+        context: Context
+    ): CastOptions = CastOptions.Builder()
+        .setReceiverApplicationId(CastMediaControlIntent.DEFAULT_MEDIA_RECEIVER_APPLICATION_ID)
+        .setCastMediaOptions(mediaOptions)
+        .setRemoteToLocalEnabled(true)
+        .build()
+
+    override fun getAdditionalSessionProviders(
+        context: Context
+    ): MutableList<SessionProvider>? = null
+
+    private class ImagePickerImpl : ImagePicker() {
+
+        override fun onPickImage(mediaMetadata: MediaMetadata?, hints: ImageHints): WebImage? {
+            val type = hints.type
+            if ((mediaMetadata == null) || !mediaMetadata.hasImages()) {
+                return null
+            }
+            val images: List<WebImage> = mediaMetadata.images
+            return if (images.size == 1) {
+                images[0]
+            } else {
+                if (type == IMAGE_TYPE_MEDIA_ROUTE_CONTROLLER_DIALOG_BACKGROUND) {
+                    images[0]
+                } else {
+                    images[1]
+                }
+            }
+        }
+    }
+
+}

+ 222 - 0
app/src/main/kotlin/org/mjdev/tvapp/cast/CastService.kt

@@ -0,0 +1,222 @@
+/*
+ *  Copyright (c) Milan Jurkulák 2024.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */
+
+package org.mjdev.tvapp.cast
+
+import android.annotation.SuppressLint
+import android.app.Service
+import android.app.UiModeManager
+import android.content.Context
+import android.content.Intent
+import android.content.pm.ServiceInfo
+import android.content.res.Configuration
+import android.os.Build
+import android.os.IBinder
+import androidx.annotation.Keep
+import androidx.annotation.RequiresApi
+import androidx.core.app.NotificationChannelCompat
+import androidx.core.app.NotificationCompat
+import androidx.core.app.NotificationManagerCompat
+import androidx.core.app.ServiceCompat
+import com.google.android.gms.cast.SessionState
+import com.google.android.gms.cast.framework.CastContext
+import com.google.android.gms.cast.framework.CastSession
+import com.google.android.gms.cast.framework.SessionManagerListener
+import com.google.android.gms.cast.framework.SessionTransferCallback
+import com.google.android.gms.common.ConnectionResult
+import com.google.android.gms.common.GoogleApiAvailability
+import org.mjdev.tvapp.R
+import timber.log.Timber
+
+@Suppress("UNUSED_PARAMETER", "unused")
+@Keep
+class CastService : Service(), SessionManagerListener<CastSession> {
+
+    private val notificationManager by lazy {
+        NotificationManagerCompat.from(this)
+    }
+
+    private val notificationChannelID
+        get() = getString(R.string.app_name)
+
+    private val isGooglePlayServicesAvailable
+        get() = GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(this) ==
+                ConnectionResult.SUCCESS
+
+    private val isNotTv: Boolean
+        get() {
+            val uiModeManager = getSystemService(UI_MODE_SERVICE) as UiModeManager
+            return uiModeManager.currentModeType != Configuration.UI_MODE_TYPE_TELEVISION
+        }
+
+    private val isCastApiAvailable: Boolean
+        get() = isNotTv && isGooglePlayServicesAvailable
+
+    @Suppress("DEPRECATION")
+    private val castContext: CastContext?
+        get() {
+            return if (isCastApiAvailable) try {
+                CastContext.getSharedInstance(this)
+            } catch (e: Exception) {
+                Timber.e(e)
+                null
+            } else null
+        }
+
+    private val sessionManager
+        get() = castContext?.sessionManager
+
+    @delegate:RequiresApi(Build.VERSION_CODES.O)
+    private val notificationChannel by lazy {
+        NotificationChannelCompat.Builder(
+            notificationChannelID,
+            NotificationManagerCompat.IMPORTANCE_DEFAULT
+        ).build().apply {
+            notificationManager.createNotificationChannel(this)
+        }
+    }
+
+    private val notification by lazy {
+        NotificationCompat
+            .Builder(
+                this,
+                notificationChannelID
+            )
+            .setContentTitle("Cast service")
+            .setContentText("Cast service")
+            .build()
+    }
+
+    private val sessionTransferCallback = CastSessionTransferCallback(
+        onMediaTransferring = { transferType ->
+            onMediaTransferring(transferType)
+        },
+        onMediaTransferred = { transferType, sessionState ->
+            onMediaTransferred(transferType, sessionState)
+        },
+        onMediaTransferFailed = { transferType, transferFailedReason ->
+            onMediaTransferFailed(transferType, transferFailedReason)
+        }
+    )
+
+    private fun onMediaTransferring(transferType: Int) {
+    }
+
+    private fun onMediaTransferred(transferType: Int, sessionState: SessionState) {
+    }
+
+    private fun onMediaTransferFailed(transferType: Int, transferFailedReason: Int) {
+
+    }
+
+    fun onTransferring(@SessionTransferCallback.TransferType transferType: Int) {
+        // Perform necessary steps prior to onTransferred
+    }
+
+    fun onTransferred(
+        @SessionTransferCallback.TransferType
+        transferType: Int,
+        sessionState: SessionState?
+    ) {
+//        if (transferType == TRANSFER_TYPE_FROM_REMOTE_TO_LOCAL) {
+//            // Remote stream is transferred to the local device.
+//            // Retrieve information from the SessionState to continue playback on the local player.
+//        }
+    }
+
+    fun onTransferFailed(
+        @SessionTransferCallback.TransferType
+        transferType: Int,
+        @SessionTransferCallback.TransferFailedReason
+        transferFailedReason: Int
+    ) {
+        // Handle transfer failure.
+    }
+
+    @SuppressLint("ForegroundServiceType")
+    override fun onCreate() {
+        try {
+            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+                ServiceCompat.startForeground(
+                    this,
+                    1,
+                    notification,
+                    ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK
+                )
+            }
+            sessionManager?.addSessionManagerListener(this, CastSession::class.java)
+            castContext?.addSessionTransferCallback(sessionTransferCallback)
+        } catch (e: Exception) {
+            Timber.e(e)
+        }
+    }
+
+    override fun onDestroy() {
+        try {
+            sessionManager?.removeSessionManagerListener(this, CastSession::class.java)
+            castContext?.removeSessionTransferCallback(sessionTransferCallback)
+        } catch (e: Exception) {
+            Timber.e(e)
+        }
+    }
+
+    override fun onBind(intent: Intent?): IBinder? = null
+
+    override fun onSessionEnded(session: CastSession, p1: Int) {
+    }
+
+    override fun onSessionEnding(session: CastSession) {
+    }
+
+    override fun onSessionResumeFailed(session: CastSession, p1: Int) {
+    }
+
+    override fun onSessionResumed(session: CastSession, p1: Boolean) {
+    }
+
+    override fun onSessionResuming(session: CastSession, p1: String) {
+    }
+
+    override fun onSessionStartFailed(session: CastSession, p1: Int) {
+    }
+
+    override fun onSessionStarted(session: CastSession, p1: String) {
+    }
+
+    override fun onSessionStarting(session: CastSession) {
+    }
+
+    override fun onSessionSuspended(session: CastSession, p1: Int) {
+    }
+
+    companion object {
+
+        // todo
+        fun start(context: Context) {
+//            try {
+//                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+//                    context.startForegroundService(Intent(context, CastService::class.java))
+//                } else {
+//                    context.startService(Intent(context, CastService::class.java))
+//                }
+//            } catch (e: Exception) {
+//                Timber.e(e)
+//            }
+        }
+
+        fun stop(context: Context) {
+            try {
+                context.stopService(Intent(context, CastService::class.java))
+            } catch (e: Exception) {
+                Timber.e(e)
+            }
+        }
+
+    }
+
+}

+ 41 - 0
app/src/main/kotlin/org/mjdev/tvapp/cast/CastSessionTransferCallback.kt

@@ -0,0 +1,41 @@
+/*
+ *  Copyright (c) Milan Jurkulák 2024.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */
+
+package org.mjdev.tvapp.cast
+
+import com.google.android.gms.cast.SessionState
+import com.google.android.gms.cast.framework.SessionTransferCallback
+
+class CastSessionTransferCallback(
+    val onMediaTransferring: (transferType: Int) -> Unit,
+    val onMediaTransferred: (transferType: Int, sessionState: SessionState) -> Unit,
+    val onMediaTransferFailed: (transferType: Int, transferFailedReason: Int) -> Unit
+) : SessionTransferCallback() {
+
+    override fun onTransferring(@TransferType transferType: Int) {
+        onMediaTransferring(transferType)
+    }
+
+    override fun onTransferred(
+        @TransferType
+        transferType: Int,
+        sessionState: SessionState
+    ) {
+        onMediaTransferred(transferType, sessionState)
+    }
+
+    override fun onTransferFailed(
+        @TransferType
+        transferType: Int,
+        @TransferFailedReason
+        transferFailedReason: Int
+    ) {
+        onMediaTransferFailed(transferType, transferFailedReason)
+    }
+
+}

+ 13 - 0
app/src/main/kotlin/org/mjdev/tvapp/data/events/PauseEvent.kt

@@ -0,0 +1,13 @@
+/*
+ *  Copyright (c) Milan Jurkulák 2024.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */
+
+package org.mjdev.tvapp.data.events
+
+data class PauseEvent(
+    val timeout: Long = 30 * 1000L
+)

+ 13 - 0
app/src/main/kotlin/org/mjdev/tvapp/data/events/SyncEnded.kt

@@ -0,0 +1,13 @@
+/*
+ *  Copyright (c) Milan Jurkulák 2024.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */
+
+package org.mjdev.tvapp.data.events
+
+data class SyncEnded(
+    val syncerName: String
+)

+ 16 - 0
app/src/main/kotlin/org/mjdev/tvapp/data/events/SyncEvent.kt

@@ -0,0 +1,16 @@
+/*
+ *  Copyright (c) Milan Jurkulák 2024.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */
+
+package org.mjdev.tvapp.data.events
+
+import org.mjdev.tvapp.data.local.Media
+
+data class SyncEvent(
+    val syncerName: String,
+    val data : Media
+)

+ 13 - 0
app/src/main/kotlin/org/mjdev/tvapp/data/events/SyncStarted.kt

@@ -0,0 +1,13 @@
+/*
+ *  Copyright (c) Milan Jurkulák 2024.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */
+
+package org.mjdev.tvapp.data.events
+
+data class SyncStarted(
+    val syncerName: String
+)

+ 11 - 2
app/src/main/kotlin/org/mjdev/tvapp/data/local/Movie.kt → app/src/main/kotlin/org/mjdev/tvapp/data/local/Media.kt

@@ -15,6 +15,7 @@ import io.objectbox.annotation.Entity
 import io.objectbox.annotation.Id
 import io.objectbox.annotation.Index
 import io.objectbox.annotation.Unique
+import org.mjdev.tvlib.data.local.IWebUrlContainer
 import org.mjdev.tvlib.interfaces.ItemWithBackground
 import org.mjdev.tvlib.interfaces.ItemWithDescription
 import org.mjdev.tvlib.interfaces.ItemWithId
@@ -29,8 +30,9 @@ import java.util.Locale
 @Suppress("unused")
 @Entity
 @JsonClass(generateAdapter = true)
-class Movie :
+class Media :
     Serializable,
+    IWebUrlContainer,
     ItemWithId,
     ItemWithTitle<String>,
     ItemWithSubtitle<String>,
@@ -54,6 +56,13 @@ class Movie :
     @Json(name = "isNsfw")
     var isNsfw: Boolean = false
 
+    @Json(name = "mimeType")
+    var mimeType: String? = null
+
+    @Json(name = "webUrl")
+    @Index
+    override var webUrl: String? = null
+
     @Json(name = "title")
     @Index
     override var title: String? = null
@@ -87,7 +96,7 @@ class Movie :
         get() = field ?: uri
 
     override fun equals(other: Any?): Boolean =
-        if (other !is Movie) false else other.hashCode() == hashCode()
+        if (other !is Media) false else other.hashCode() == hashCode()
 
     override fun hashCode(): Int {
         var result = id.hashCode()

+ 37 - 0
app/src/main/kotlin/org/mjdev/tvapp/data/local/ParsedLink.kt

@@ -0,0 +1,37 @@
+/*
+ *  Copyright (c) Milan Jurkulák 2024.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */
+
+package org.mjdev.tvapp.data.local
+
+import androidx.annotation.Keep
+import com.squareup.moshi.Json
+import com.squareup.moshi.JsonClass
+import io.objectbox.annotation.Entity
+import io.objectbox.annotation.Id
+
+@Keep
+@Entity
+@JsonClass(generateAdapter = true)
+class ParsedLink {
+
+    @Id
+    @Json(name = "id")
+    var id: Long = 0
+
+    @Json(name = "title")
+    var title: String? = ""
+
+    @Json(name = "title")
+    var url: String = ""
+
+    @Json(name = "title")
+    var thumbUrl: String? = ""
+
+    @Json(name = "parsed")
+    var parsed: Long = 0L
+}

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

@@ -175,7 +175,7 @@ class TVChannel() :
 
     companion object {
 
-        fun TVChannel.asMovie(category: String): Movie = Movie().also { m ->
+        fun TVChannel.asMovie(category: String): Media = Media().also { m ->
             m.category = category
             m.country = country
             m.isNsfw = isNsfw ?: false

+ 2 - 1
app/src/main/kotlin/org/mjdev/tvapp/data/remote/Epg.kt

@@ -47,4 +47,5 @@ class Epg {
         result = 31 * result + (url?.hashCode() ?: 0)
         return result
     }
-}
+
+}

+ 2 - 1
app/src/main/kotlin/org/mjdev/tvapp/data/remote/Stream.kt

@@ -39,4 +39,5 @@ class Stream {
         result = 31 * result + (userAgent?.hashCode() ?: 0)
         return result
     }
-}
+
+}

+ 13 - 4
app/src/main/kotlin/org/mjdev/tvapp/database/DAO.kt

@@ -1,5 +1,5 @@
 /*
- *  Copyright (c) Milan Jurkulák 2023.
+ *  Copyright (c) Milan Jurkulák 2024.
  *  Contact:
  *  e: mimoccc@gmail.com
  *  e: mj@mjdev.org
@@ -17,8 +17,9 @@ import org.mjdev.tvapp.BuildConfig
 import org.mjdev.tvapp.data.local.Category
 import org.mjdev.tvapp.data.local.Country
 import org.mjdev.tvapp.data.local.Message
-import org.mjdev.tvapp.data.local.Movie
+import org.mjdev.tvapp.data.local.Media
 import org.mjdev.tvapp.data.local.MyObjectBox
+import org.mjdev.tvapp.data.local.ParsedLink
 import org.mjdev.tvapp.data.local.TVChannel
 import kotlin.reflect.KClass
 
@@ -40,7 +41,7 @@ class DAO(
 
     val cache = mutableMapOf<KClass<*>, List<*>>()
 
-    val movieDao: Box<Movie> by lazy {
+    val movieDao: Box<Media> by lazy {
         store.boxFor()
     }
 
@@ -62,10 +63,18 @@ class DAO(
         store.boxFor()
     }
 
+    val parsedLinksDao: Box<ParsedLink> by lazy {
+        store.boxFor()
+    }
+
     val allMediaItems: List<Any> by lazy {
         movieDao.all
     }
 
+    val parsedLinks: List<ParsedLink> by lazy {
+        parsedLinksDao.all
+    }
+
     inline fun <reified T> getCachedList(creator: () -> List<*>): List<*> {
         if (!cache.containsKey(T::class)) {
             creator().let { list ->
@@ -75,4 +84,4 @@ class DAO(
         return cache[T::class] ?: emptyList<Any>()
     }
 
-}
+}

+ 10 - 11
app/src/main/kotlin/org/mjdev/tvapp/module/ProvideModule.kt

@@ -20,7 +20,6 @@ import dagger.hilt.InstallIn
 import dagger.hilt.android.qualifiers.ApplicationContext
 import dagger.hilt.components.SingletonComponent
 import okhttp3.OkHttpClient
-import okhttp3.logging.HttpLoggingInterceptor
 import org.mjdev.tvapp.BuildConfig
 import org.mjdev.tvapp.repository.ApiService
 import org.mjdev.tvlib.helpers.cursor.AudioCursor
@@ -77,12 +76,12 @@ class ProvideModule {
         context: Context
     ): NetworkConnectivityService = NetworkConnectivityService(context)
 
-    @Singleton
-    @Provides
-    fun providesHttpLoggingInterceptor() = HttpLoggingInterceptor().setLevel(
-        if (BuildConfig.DEBUG) HttpLoggingInterceptor.Level.BODY
-        else HttpLoggingInterceptor.Level.NONE
-    )
+//    @Singleton
+//    @Provides
+//    fun providesHttpLoggingInterceptor() = HttpLoggingInterceptor().setLevel(
+//        if (BuildConfig.DEBUG) HttpLoggingInterceptor.Level.BODY
+//        else HttpLoggingInterceptor.Level.NONE
+//    )
 
 //    @Singleton
 //    @Provides
@@ -111,7 +110,7 @@ class ProvideModule {
     fun providesOkHttpClient(
         networkConnectionInterceptor: NetworkConnectionInterceptor,
         userAgentInterceptor: UserAgentInterceptor,
-        httpLoggingInterceptor: HttpLoggingInterceptor,
+//        httpLoggingInterceptor: HttpLoggingInterceptor,
 //        cacheInterceptor: CacheInterceptor,
 //        adBlockInterceptor: AdBlockInterceptor,
     ): OkHttpClient = OkHttpClient.Builder().apply {
@@ -119,9 +118,9 @@ class ProvideModule {
         addNetworkInterceptor(userAgentInterceptor)
 //        addNetworkInterceptor(adBlockInterceptor)
 //        addNetworkInterceptor(cacheInterceptor)
-        if (BuildConfig.DEBUG) {
-            addInterceptor(httpLoggingInterceptor)
-        }
+//        if (BuildConfig.DEBUG) {
+//            addInterceptor(httpLoggingInterceptor)
+//        }
     }.build()
 
     @Singleton

+ 58 - 12
app/src/main/kotlin/org/mjdev/tvapp/sync/SyncAdapter.kt

@@ -9,20 +9,28 @@
 package org.mjdev.tvapp.sync
 
 import android.accounts.Account
+import android.annotation.SuppressLint
 import android.content.AbstractThreadedSyncAdapter
 import android.content.ContentProviderClient
 import android.content.Context
 import android.content.SyncResult
+import android.net.ConnectivityManager
 import android.os.Bundle
 import androidx.annotation.Keep
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.rememberCoroutineScope
 import io.objectbox.Box
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
 import org.mjdev.tvapp.BuildConfig
-import org.mjdev.tvapp.data.local.Movie
+import org.mjdev.tvapp.data.events.PauseEvent
+import org.mjdev.tvapp.data.local.Media
 import org.mjdev.tvapp.database.DAO
 import org.mjdev.tvapp.repository.ApiService
 import org.mjdev.tvapp.sync.base.Syncer
 import org.mjdev.tvapp.sync.syncers.GithubMoviesSyncer
-import org.mjdev.tvapp.sync.syncers.WebMovieSyncer
+import org.mjdev.tvlib.events.post.postEvent
 import org.mjdev.tvlib.extensions.GlobalExt.launchIO
 
 @Keep
@@ -31,20 +39,30 @@ class SyncAdapter(
     context: Context,
     val apiService: ApiService,
     val dao: DAO,
-    val movieDao: Box<Movie> = dao.movieDao,
+    val movieDao: Box<Media> = dao.movieDao,
 ) : AbstractThreadedSyncAdapter(context, true, false) {
 
+    private val connManager by lazy {
+        context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
+    }
+
+    private val mWifi by lazy {
+        @Suppress("DEPRECATION")
+        connManager.getNetworkInfo(ConnectivityManager.TYPE_WIFI)
+    }
+
     private fun ifDebug(syncer: Syncer) = if (BuildConfig.DEBUG) syncer else null
     private fun ifNotDebug(syncer: Syncer) = if (!BuildConfig.DEBUG) syncer else null
 
     private val customSyncers: List<Syncer> by lazy {
-        listOfNotNull(
-            ifNotDebug(GithubMoviesSyncer(this)),
-            ifDebug(WebMovieSyncer(this, "https://tekeye.uk/html/html5-video-test-page")),
-            ifDebug(WebMovieSyncer(this, "https://prehraj.to/hledej/filmy")),
-            ifDebug(WebMovieSyncer(this, "https://movie4kto.net/")),
-            ifDebug(WebMovieSyncer(this, "https://movie4kto.net/")),
-        )
+        @Suppress("DEPRECATION")
+        if (mWifi?.isConnected == true) {
+            listOfNotNull(
+                GithubMoviesSyncer(this),
+            )
+        } else {
+            emptyList()
+        }
     }
 
     override fun onPerformSync(
@@ -55,8 +73,36 @@ class SyncAdapter(
         syncResult: SyncResult?
     ) {
         launchIO {
-            customSyncers.forEach { syncer -> syncer.sync() }
+            customSyncers.forEach { syncer ->
+                syncer.sync(this)
+            }
         }
     }
-}
 
+    companion object {
+
+        fun pauseSync() =
+            postEvent(PauseEvent())
+
+        @SuppressLint("ComposableNaming")
+        @Composable
+        fun pauseSyncUntilGone() {
+            val coroutineScope = rememberCoroutineScope()
+            var disposed = false
+            DisposableEffect(Unit) {
+                coroutineScope.launch {
+                    do {
+                        PauseEvent().let { event ->
+                            postEvent(event)
+                            delay(event.timeout / 2)
+                        }
+                    } while (!disposed)
+                }
+                onDispose {
+                    disposed = true
+                }
+            }
+        }
+    }
+
+}

+ 15 - 1
app/src/main/kotlin/org/mjdev/tvapp/sync/SyncService.kt

@@ -1,3 +1,11 @@
+/*
+ *  Copyright (c) Milan Jurkulák 2024.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */
+
 package org.mjdev.tvapp.sync
 
 import android.accounts.Account
@@ -87,6 +95,12 @@ class SyncService : Service() {
             )
         }
 
+        fun Context.createAccountAndSync() {
+            createSyncAccount()?.let { account ->
+                requestSync(account)
+            }
+        }
+
     }
 
-}
+}

+ 22 - 0
app/src/main/kotlin/org/mjdev/tvapp/sync/SyncStarter.kt

@@ -0,0 +1,22 @@
+/*
+ *  Copyright (c) Milan Jurkulák 2024.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */
+
+package org.mjdev.tvapp.sync
+
+import android.content.Context
+import android.content.Intent
+import android.content.BroadcastReceiver
+import org.mjdev.tvapp.sync.SyncService.Companion.createAccountAndSync
+
+class SyncStarter : BroadcastReceiver() {
+
+    override fun onReceive(context: Context, intent: Intent?) {
+        context.createAccountAndSync()
+    }
+
+}

+ 9 - 7
app/src/main/kotlin/org/mjdev/tvapp/sync/base/SyncItems.kt

@@ -8,17 +8,18 @@
 
 package org.mjdev.tvapp.sync.base
 
-import org.mjdev.tvapp.data.local.Movie
+import org.mjdev.tvapp.data.local.Media
 import org.mjdev.tvlib.interfaces.ItemWithUri.Companion.hasUri
 
 @Suppress("unused", "MemberVisibilityCanBePrivate")
 class SyncItems(
-    private val oldMovies: List<Movie>,
-    private val newMovies: List<Movie>
+    private val oldMovies: List<Media>,
+    private val newMovies: List<Media>
 ) {
-    val toAdd = mutableListOf<Movie>()
-    val toUpdate = mutableListOf<Movie>()
-    val toRemove = mutableListOf<Movie>()
+
+    val toAdd = mutableListOf<Media>()
+    val toUpdate = mutableListOf<Media>()
+    val toRemove = mutableListOf<Media>()
 
     init {
         newMovies.filter { newMovie ->
@@ -43,4 +44,5 @@ class SyncItems(
             toRemove.addAll(addMovies)
         }
     }
-}
+
+}

+ 54 - 2
app/src/main/kotlin/org/mjdev/tvapp/sync/base/Syncer.kt

@@ -9,16 +9,68 @@
 package org.mjdev.tvapp.sync.base
 
 import android.content.Context
+import androidx.annotation.CallSuper
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.mutableStateOf
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import org.mjdev.tvapp.data.events.PauseEvent
+import org.mjdev.tvapp.data.events.SyncEnded
+import org.mjdev.tvapp.data.events.SyncStarted
 import org.mjdev.tvapp.sync.SyncAdapter
+import org.mjdev.tvlib.events.observe.observeEvent
+import org.mjdev.tvlib.events.post.postEvent
 
+@Suppress("unused", "MemberVisibilityCanBePrivate")
 abstract class Syncer(
-    private val adapter: SyncAdapter
+    val adapter: SyncAdapter,
+    val syncerName: String = Syncer::class.java.simpleName,
+    val pauseState: MutableState<Boolean> = mutableStateOf(false)
 ) {
     val context: Context get() = adapter.context
     val dao get() = adapter.dao
     val apiService get() = adapter.apiService
     val movieDao get() = adapter.movieDao
 
-    abstract suspend fun sync()
+    private var pauseJob: Job? = null
+
+    private fun pause(
+        coroutineScope: CoroutineScope,
+        timeout: Long
+    ) = with(coroutineScope) {
+        pauseJob?.cancel()
+        pauseJob = launch {
+            pauseState.value = true
+            delay(timeout)
+            pauseState.value = false
+            pauseJob = null
+        }
+    }
+
+    @CallSuper
+    open suspend fun onSyncStart() {
+        postEvent(SyncStarted(syncerName))
+    }
+
+    @CallSuper
+    suspend fun sync(coroutineScope: CoroutineScope) {
+        with(coroutineScope) {
+            while(pauseState.value) delay(500)
+            onSyncStart()
+            observeEvent<PauseEvent> { event ->
+                pause(coroutineScope, event.timeout)
+            }
+            doSync()
+        }
+    }
+
+    open suspend fun doSync() {
+    }
+
+    open suspend fun onSyncFinish() {
+        postEvent(SyncEnded(syncerName))
+    }
 
 }

+ 2 - 2
app/src/main/kotlin/org/mjdev/tvapp/sync/provider/DataProvider.kt

@@ -16,7 +16,7 @@ import android.net.Uri
 import androidx.annotation.Keep
 import com.squareup.moshi.Moshi
 import org.mjdev.tvapp.BuildConfig
-import org.mjdev.tvapp.data.local.Movie
+import org.mjdev.tvapp.data.local.Media
 import org.mjdev.tvapp.database.DAO
 import javax.inject.Inject
 
@@ -76,7 +76,7 @@ class DataProvider : ContentProvider() {
 
         MOVIE -> {
             if (values?.containsKey(DATA) == true) {
-                moshi.adapter(Movie::class.java)
+                moshi.adapter(Media::class.java)
                     .fromJson(values.getAsString(DATA))
                     .also { m ->
                         if (m != null) dao.movieDao.put(m)

+ 14 - 5
app/src/main/kotlin/org/mjdev/tvapp/sync/syncers/GithubMoviesSyncer.kt

@@ -8,7 +8,8 @@
 
 package org.mjdev.tvapp.sync.syncers
 
-import org.mjdev.tvapp.data.local.Movie
+import androidx.compose.runtime.MutableState
+import org.mjdev.tvapp.data.local.Media
 import org.mjdev.tvapp.sync.SyncAdapter
 import org.mjdev.tvapp.sync.base.SyncItems
 import org.mjdev.tvapp.sync.base.Syncer
@@ -17,20 +18,24 @@ import org.mjdev.tvlib.database.extensions.ObjectBoxExt.update
 import org.mjdev.tvlib.extensions.ApiResponseExt.safeGet
 import timber.log.Timber
 
-class GithubMoviesSyncer(adapter: SyncAdapter) : Syncer(adapter) {
-    override suspend fun sync() {
+class GithubMoviesSyncer(
+    adapter: SyncAdapter,
+    syncerName: String = GithubMoviesSyncer::class.java.simpleName
+) : Syncer(adapter, syncerName) {
+
+    override suspend fun doSync() {
         val streams = apiService.streams().safeGet()
         val channels = apiService.channels().safeGet()
 
         val oldMovies = movieDao.all
-        val newMovies = mutableListOf<Movie>()
+        val newMovies = mutableListOf<Media>()
 
         streams.forEach { stream ->
             channels.firstOrNull { channel ->
                 channel.id == stream.channelId
             }.also { channel ->
                 (channel?.categories ?: listOf(null)).forEach { ctg ->
-                    newMovies.add(Movie().apply {
+                    newMovies.add(Media().apply {
                         title = channel?.name ?: stream.channelId
                         uri = stream.url
                         category = ctg
@@ -51,6 +56,7 @@ class GithubMoviesSyncer(adapter: SyncAdapter) : Syncer(adapter) {
                     Timber.e(e)
                 }
             }
+
             toUpdate.forEach { movie ->
                 try {
                     dao.movieDao.stx { update(movie) { m -> m.uri == movie.uri } }
@@ -59,6 +65,7 @@ class GithubMoviesSyncer(adapter: SyncAdapter) : Syncer(adapter) {
                     Timber.e(e)
                 }
             }
+
             toRemove.forEach { movie ->
                 try {
                     dao.movieDao.stx { remove(movie) }
@@ -68,5 +75,7 @@ class GithubMoviesSyncer(adapter: SyncAdapter) : Syncer(adapter) {
                 }
             }
         }
+
     }
+
 }

+ 0 - 42
app/src/main/kotlin/org/mjdev/tvapp/sync/syncers/WebMovieSyncer.kt

@@ -1,42 +0,0 @@
-/*
- *  Copyright (c) Milan Jurkulák 2023.
- *  Contact:
- *  e: mimoccc@gmail.com
- *  e: mj@mjdev.org
- *  w: https://mjdev.org
- */
-
-package org.mjdev.tvapp.sync.syncers
-
-import org.mjdev.tvapp.data.local.Movie
-import org.mjdev.tvapp.sync.SyncAdapter
-import org.mjdev.tvapp.sync.base.Syncer
-import org.mjdev.tvlib.database.extensions.ObjectBoxExt.tx
-import org.mjdev.tvlib.database.extensions.ObjectBoxExt.update
-import org.mjdev.tvlib.webscrapper.WebVideoScrapper
-import org.mjdev.tvlib.webscrapper.adblock.AdBlock
-
-class WebMovieSyncer(
-    adapter: SyncAdapter,
-    private val url: String
-) : Syncer(adapter) {
-    override suspend fun sync() {
-        WebVideoScrapper(
-            context = context,
-            addBlock = AdBlock(context),
-            baseUrls = listOf(url),
-            onVideoFound = { video ->
-                Movie().apply {
-                    category = video.category
-                    title = video.title
-                    image = video.thumb
-                    uri = video.url
-                }.also { movie ->
-                    dao.movieDao.tx {
-                        update(movie) { m -> m.uri == video.url }
-                    }
-                }
-            }
-        ).start()
-    }
-}

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

@@ -49,8 +49,8 @@ class AboutPage : Page() {
         ) {
             Card(
                 modifier = Modifier.fillMaxSize(),
-                onClick = {},
-                onLongClick = {},
+                onClick = null,
+                onLongClick = null,
                 shape = CardDefaults.shape(),
                 aspectRatio = null,
                 colors = CardDefaults.colors(
@@ -96,4 +96,4 @@ class AboutPage : Page() {
         }
     }
 
-}
+}

+ 59 - 34
app/src/main/kotlin/org/mjdev/tvapp/ui/pages/MainPage.kt

@@ -12,22 +12,29 @@ import android.annotation.SuppressLint
 import android.net.Uri
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
 import androidx.compose.material.icons.Icons
 import androidx.compose.material.icons.filled.Home
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.unit.dp
 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
+import org.mjdev.tvapp.data.events.SyncEvent
+import org.mjdev.tvapp.sync.SyncAdapter.Companion.pauseSync
 import org.mjdev.tvlib.data.local.User
 import org.mjdev.tvlib.auth.AuthManager.Companion.rememberAuthManager
 import org.mjdev.tvlib.extensions.HiltExt.appViewModel
-import org.mjdev.tvlib.interfaces.ItemWithId
 import org.mjdev.tvlib.ui.components.page.Page
 import org.mjdev.tvlib.ui.components.tv.AppsRow
 import org.mjdev.tvlib.ui.components.tv.BrowseView
@@ -36,10 +43,12 @@ import org.mjdev.tvlib.ui.components.tv.LocalPhotosRow
 import org.mjdev.tvlib.ui.components.tv.LocalVideoRow
 import org.mjdev.tvapp.viewmodel.MainViewModel
 import org.mjdev.tvlib.annotations.Previews
+import org.mjdev.tvlib.extensions.ComposeExt.observedEvents
 import org.mjdev.tvlib.extensions.NavExt.rememberNavControllerEx
 import org.mjdev.tvlib.interfaces.ItemPhoto
 import org.mjdev.tvlib.interfaces.ItemWithBackground
 import org.mjdev.tvlib.interfaces.ItemWithImage
+import org.mjdev.tvlib.ui.dialog.BlurDialog
 import java.io.Serializable
 import java.net.URL
 
@@ -49,6 +58,7 @@ class MainPage : Page() {
     override val title: Int = R.string.title_home
     override val icon: ImageVector = Icons.Default.Home
 
+    @SuppressLint("OpaqueUnitKey")
     @Previews
     @Composable
     override fun Content() {
@@ -58,26 +68,40 @@ class MainPage : Page() {
             MainViewModel.mock(context)
         }
 
-        val streamingData = viewModel.movieList.collectAsState()
-        val countryList = viewModel.countryList.collectAsState()
-        val featuredMovieList = viewModel.featuredMovieList.collectAsState()
-        val messages = viewModel.messages.collectAsState()
-
-        val errorState = remember(viewModel.error) { mutableStateOf(viewModel.error.value) }
-        val titleState = remember { mutableStateOf<Any?>(R.string.app_name) }
-
-        val onItemClick: (item: Any?) -> Unit = { item ->
-            val dataId = (item as? ItemWithId)?.id
-            if (dataId != null) {
-                viewModel.findMovie(dataId) { movie ->
-                    navController.startActivity<IPTVActivity>(IPTV_DATA to movie)
-                }
-            } else if (item is Serializable) {
-                navController.startActivity<IPTVActivity>(IPTV_DATA to item)
+        val syncEvents by observedEvents<SyncEvent>()
+        // refresh every 32 items, todo improve
+        val needRefresh by remember {
+            derivedStateOf {
+                syncEvents.size > 32
+                syncEvents.clear()
             }
         }
 
-        val onItemSelect: (item: Any?) -> Unit = { item ->
+        val titleState by remember { mutableIntStateOf(R.string.app_name) }
+        // todo improve
+        val streamingData by remember(needRefresh) { viewModel.movieList }.collectAsState()
+        val countryList by remember { viewModel.countryList }.collectAsState()
+        val featuredMovieList by remember { viewModel.featuredMovieList }.collectAsState()
+        val messages by remember { viewModel.messages }.collectAsState()
+        var user by remember { mutableStateOf<User?>(null) }
+        val errorState = remember(viewModel.error) {
+            mutableStateOf(viewModel.error.value)
+        }
+        val authManager = rememberAuthManager(
+            allowedPackages = listOf(BuildConfig.APPLICATION_ID)
+        ) { u -> user = u }
+
+        val onItemClick: (
+            item: Any?
+        ) -> Unit = { item ->
+            pauseSync()
+            navController.startActivity<IPTVActivity>(IPTV_DATA to (item as Serializable))
+        }
+        val onItemSelect: (
+            item: Any?,
+            fromUser: Boolean
+        ) -> Unit = { item, fromUser ->
+            if (fromUser) pauseSync()
             when (item) {
                 is String -> item
                 is URL -> item
@@ -95,27 +119,20 @@ class MainPage : Page() {
             errorState.value = error
         }
 
-        val user = remember { mutableStateOf<User?>(null) }
-        val authManager = rememberAuthManager(
-            allowedPackages = listOf(BuildConfig.APPLICATION_ID)
-        ) { u -> user.value = u }
-        val apps = remember { viewModel.apps }.collectAsState(emptyList())
-
         Box(
             modifier = Modifier.fillMaxSize(),
         ) {
             BrowseView(
-                isDebug = BuildConfig.DEBUG,
                 modifier = Modifier.fillMaxSize(),
                 appIcon = R.mipmap.ic_launcher_foreground,
-                userIcon = user.value?.pictureUrl ?: org.mjdev.tvlib.R.drawable.person,
+                userIcon = user?.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,
+                title = titleState,
+                messages = messages,
+                categories = countryList,
+                featuredItems = featuredMovieList,
+                categoriesAndItemsMap = streamingData,
                 errorState = errorState,
                 onTitleClicked = {
                     navController.openMenu()
@@ -131,9 +148,7 @@ class MainPage : Page() {
                 },
                 customRows = mutableListOf<@Composable () -> Unit>().apply {
                     add {
-                        AppsRow(
-                            apps = apps
-                        )
+                        AppsRow()
                     }
                     if (viewModel.localAudioCursor.count > 0) add {
                         LocalAudioRow(
@@ -158,6 +173,16 @@ class MainPage : Page() {
                     }
                 }
             )
+//            BlurDialog(
+//                modifier = Modifier
+//                    .fillMaxSize()
+//                    .padding(16.dp),
+//                title = "Welcome",
+//                noiseAlpha = 1f,
+//                backgroundAlpha = 0.5f
+//            ) {
+//                // todo
+//            }
         }
     }
 

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

@@ -45,8 +45,8 @@ class PluginsPage : Page() {
                 .padding(16.dp)
         ) {
             Card(
-                onClick = {},
-                onLongClick = {},
+                onClick = null,
+                onLongClick = null,
                 modifier = Modifier.fillMaxSize(),
                 shape = CardDefaults.shape(),
                 aspectRatio = null,

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

@@ -46,8 +46,8 @@ class SubscriptionPage : Page() {
                 .padding(16.dp)
         ) {
             Card(
-                onClick = {},
-                onLongClick = {},
+                onClick = null,
+                onLongClick = null,
                 modifier = Modifier.fillMaxSize(),
                 shape = CardDefaults.shape(),
                 aspectRatio = null,

+ 3 - 0
app/src/main/kotlin/org/mjdev/tvapp/ui/screens/GalleryScreen.kt

@@ -19,6 +19,7 @@ import androidx.compose.ui.Modifier
 import androidx.compose.ui.graphics.vector.ImageVector
 import androidx.navigation.navArgument
 import org.mjdev.tvapp.R
+import org.mjdev.tvapp.sync.SyncAdapter.Companion.pauseSyncUntilGone
 import org.mjdev.tvapp.viewmodel.DetailViewModel
 import org.mjdev.tvlib.annotations.Previews
 import org.mjdev.tvlib.extensions.HiltExt.appViewModel
@@ -58,6 +59,8 @@ class GalleryScreen : Screen() {
             viewModel.mediaItemsFor(data)
         }
 
+        pauseSyncUntilGone()
+
         Gallery(
             modifier = Modifier.fillMaxSize(),
             list = dataList,

+ 22 - 8
app/src/main/kotlin/org/mjdev/tvapp/ui/screens/IPTVScreen.kt

@@ -6,6 +6,8 @@
  * w: https://mjdev.org
  */
 
+@file:Suppress("UnusedReceiverParameter")
+
 package org.mjdev.tvapp.ui.screens
 
 import androidx.compose.foundation.layout.fillMaxSize
@@ -18,6 +20,7 @@ import androidx.compose.ui.graphics.vector.ImageVector
 import androidx.media3.common.MediaItem
 import androidx.navigation.navArgument
 import org.mjdev.tvapp.R
+import org.mjdev.tvapp.sync.SyncAdapter.Companion.pauseSyncUntilGone
 import org.mjdev.tvapp.viewmodel.IPTVViewModel
 import org.mjdev.tvlib.annotations.Previews
 import org.mjdev.tvlib.extensions.HiltExt.appViewModel
@@ -27,6 +30,7 @@ import org.mjdev.tvlib.ui.components.media.MediaPlayerContainer
 import org.mjdev.tvlib.extensions.MediaItemExt.uri
 import org.mjdev.tvlib.extensions.ListExt.indexOf
 import org.mjdev.tvlib.ui.components.media.MediaPlayerState.Companion.rememberMediaPlayerState
+import timber.log.Timber
 
 @androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
 class IPTVScreen : Screen() {
@@ -64,22 +68,32 @@ class IPTVScreen : Screen() {
             dataList.indexOf<Any?> { item -> item.uri == data.uri }
         }
 
+        pauseSyncUntilGone()
+
 //        Gallery (
 //            modifier = Modifier.fillMaxSize(),
 //            list = dataList,
 //            index = index,
 //            customContentViewer = { _, type, _ ->
-                MediaPlayerContainer(
-                    modifier = Modifier.fillMaxSize(),
+        MediaPlayerContainer(
+            modifier = Modifier.fillMaxSize(),
 //                    visible = (type == ItemType.Video || type == ItemType.Audio),
-                    state = rememberMediaPlayerState(
-                        items = dataList,
-                        itemToPlay = index
-                    )
-                )
+            state = rememberMediaPlayerState(
+                items = dataList,
+                itemToPlay = index,
+                autoPlay = true,
+                playNextOnError = false,
+                onError = { error ->
+                    Timber.e(error)
+                    true
+                }
+            )
+        )
 //            }
 //        )
 
     }
 
-}
+}
+
+

+ 3 - 12
app/src/main/kotlin/org/mjdev/tvapp/ui/screens/LoadingScreen.kt

@@ -1,5 +1,5 @@
 /*
- *  Copyright (c) Milan Jurkulák 2023.
+ *  Copyright (c) Milan Jurkulák 2024.
  *  Contact:
  *  e: mimoccc@gmail.com
  *  e: mj@mjdev.org
@@ -8,7 +8,6 @@
 
 package org.mjdev.tvapp.ui.screens
 
-import androidx.compose.foundation.background
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.material.icons.Icons
@@ -16,16 +15,10 @@ import androidx.compose.material.icons.filled.Tv
 import androidx.compose.runtime.Composable
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.graphics.RectangleShape
 import androidx.compose.ui.graphics.vector.ImageVector
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.text.font.FontWeight
-import androidx.compose.ui.unit.sp
 import org.mjdev.tvapp.R
 import org.mjdev.tvlib.annotations.Previews
 import org.mjdev.tvlib.screen.Screen
-import org.mjdev.tvlib.ui.components.text.TextAny
 
 class LoadingScreen : Screen() {
 
@@ -38,12 +31,10 @@ class LoadingScreen : Screen() {
     @Composable
     override fun Content() {
         Box(
-            modifier = Modifier
-                .fillMaxSize()
-                .background(Color.Black, RectangleShape),
+            modifier = Modifier.fillMaxSize(),
             contentAlignment = Alignment.Center,
         ) {
         }
     }
 
-}
+}

+ 1 - 0
app/src/main/kotlin/org/mjdev/tvapp/ui/screens/MainScreen.kt

@@ -22,6 +22,7 @@ import org.mjdev.tvlib.screen.ScreenWithPages
 import org.mjdev.tvlib.ui.components.page.PagerScope
 import org.mjdev.tvlib.ui.components.page.SearchPage
 
+@Suppress("PreviewShouldNotBeCalledRecursively")
 class MainScreen : ScreenWithPages() {
 
     override val title: Int = R.string.app_name

+ 12 - 13
app/src/main/kotlin/org/mjdev/tvapp/ui/screens/SplashScreen.kt

@@ -1,9 +1,9 @@
 /*
- * Copyright (c) Milan Jurkulák 2023.
- * Contact:
- * e: mimoccc@gmail.com
- * e: mj@mjdev.org
- * w: https://mjdev.org
+ *  Copyright (c) Milan Jurkulák 2024.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
  */
 
 package org.mjdev.tvapp.ui.screens
@@ -11,7 +11,6 @@ package org.mjdev.tvapp.ui.screens
 import android.view.animation.OvershootInterpolator
 import androidx.compose.animation.core.Animatable
 import androidx.compose.animation.core.tween
-import androidx.compose.foundation.background
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.foundation.layout.padding
@@ -22,7 +21,6 @@ import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.draw.scale
 import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.graphics.RectangleShape
 import androidx.compose.ui.text.font.FontWeight
 import androidx.compose.ui.unit.dp
 import androidx.compose.ui.unit.sp
@@ -45,7 +43,6 @@ import org.mjdev.tvlib.ui.components.text.TextAny
 class SplashScreen : Screen() {
 
     override val showOnce: Boolean = true
-    override val backgroundColor: Color = Color.Transparent
     override val immersive: Boolean = true
 
     @OptIn(ExperimentalPermissionsApi::class)
@@ -61,11 +58,13 @@ class SplashScreen : Screen() {
         val progress = animateLottieCompositionAsState(composition.value)
         val navController = rememberNavControllerEx()
 
+        // todo
+//        navController.setBackground( Color.Black)
+
         Box(
             modifier = Modifier
-                .scale(scale.value)
-                .background(backgroundColor, RectangleShape)
-                .fillMaxSize(),
+                .fillMaxSize()
+                .scale(scale.value),
             contentAlignment = Alignment.Center
         ) {
 //            AnalogTvNoise(
@@ -94,7 +93,7 @@ class SplashScreen : Screen() {
                 color = Color.White.copy(alpha = 0.5f)
             )
         }
-        LaunchedEffect(key1 = true) {
+        LaunchedEffect(Unit) {
             scale.animateTo(
                 targetValue = 1f,
                 animationSpec = tween(
@@ -116,4 +115,4 @@ class SplashScreen : Screen() {
         }
     }
 
-}
+}

+ 12 - 23
app/src/main/kotlin/org/mjdev/tvapp/ui/screens/WebScreen.kt

@@ -1,26 +1,23 @@
 /*
- * Copyright (c) Milan Jurkulák 2023.
- * Contact:
- * e: mimoccc@gmail.com
- * e: mj@mjdev.org
- * w: https://mjdev.org
+ *  Copyright (c) Milan Jurkulák 2024.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
  */
 
 package org.mjdev.tvapp.ui.screens
 
-import androidx.compose.foundation.background
-import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.material.icons.Icons
 import androidx.compose.material.icons.filled.NetworkCell
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.remember
 import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.graphics.RectangleShape
 import androidx.compose.ui.graphics.vector.ImageVector
 import androidx.navigation.navArgument
 import org.mjdev.tvapp.R
+import org.mjdev.tvapp.sync.SyncAdapter.Companion.pauseSyncUntilGone
 import org.mjdev.tvlib.annotations.Previews
 import org.mjdev.tvlib.navigation.AnyType
 import org.mjdev.tvlib.screen.Screen
@@ -45,20 +42,12 @@ class WebScreen : Screen() {
     @Previews
     @Composable
     override fun Content() {
-
         val data: Any? = remember(args) { args[data] }
-
-        Box(
-            modifier = Modifier
-                .fillMaxSize()
-                .background(Color.Black, RectangleShape),
-        ) {
-            WebView(
-                modifier = Modifier.fillMaxSize(),
-                url = data?.toString() ?: "about:blank"
-            )
-        }
-
+        pauseSyncUntilGone()
+        WebView(
+            modifier = Modifier.fillMaxSize(),
+            url = data?.toString() ?: "about:blank",
+        )
     }
 
-}
+}

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

@@ -12,7 +12,7 @@ package org.mjdev.tvapp.viewmodel
 
 import android.content.Context
 import dagger.hilt.android.lifecycle.HiltViewModel
-import org.mjdev.tvapp.data.local.Movie
+import org.mjdev.tvapp.data.local.Media
 import org.mjdev.tvlib.helpers.cursor.AudioCursor
 import org.mjdev.tvlib.helpers.cursor.PhotoCursor
 import org.mjdev.tvlib.helpers.cursor.VideoCursor
@@ -46,7 +46,7 @@ class DetailViewModel @Inject constructor() : BaseViewModel() {
         is ItemAudio -> dao.getCachedList<ItemAudio> { localAudioCursor }
         is ItemVideo -> dao.getCachedList<ItemVideo> { localVideoCursor }
         is ItemPhoto -> dao.getCachedList<ItemPhoto> { localPhotoCursor }
-        is Movie -> dao.getCachedList<Movie> { dao.allMediaItems }
+        is Media -> dao.getCachedList<Media> { dao.allMediaItems }
         else -> listOf(data.mediaItem)
     }
 

+ 3 - 3
app/src/main/kotlin/org/mjdev/tvapp/viewmodel/IPTVViewModel.kt

@@ -13,7 +13,7 @@ package org.mjdev.tvapp.viewmodel
 import android.content.Context
 import androidx.media3.common.MediaItem
 import dagger.hilt.android.lifecycle.HiltViewModel
-import org.mjdev.tvapp.data.local.Movie
+import org.mjdev.tvapp.data.local.Media
 import org.mjdev.tvlib.helpers.cursor.AudioCursor
 import org.mjdev.tvlib.helpers.cursor.PhotoCursor
 import org.mjdev.tvlib.helpers.cursor.VideoCursor
@@ -56,9 +56,9 @@ class IPTVViewModel @Inject constructor() : BaseViewModel() {
         is ItemAudio -> getCachedList<ItemAudio> { localAudioCursor }
         is ItemVideo -> getCachedList<ItemVideo> { localVideoCursor }
         is ItemPhoto -> getCachedList<ItemPhoto> { localPhotoCursor }
-        is Movie -> getCachedList<Movie> {
+        is Media -> getCachedList<Media> {
             mediaItems.filter { listItem ->
-                (listItem as? Movie?)?.category == data.category
+                (listItem as? Media?)?.category == data.category
             }
         }
         else -> listOf(data.mediaItem)

+ 42 - 46
app/src/main/kotlin/org/mjdev/tvapp/viewmodel/MainViewModel.kt

@@ -13,39 +13,35 @@ import android.content.Context
 import dagger.hilt.android.lifecycle.HiltViewModel
 import dagger.hilt.android.qualifiers.ApplicationContext
 import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.flow.StateFlow
 import kotlinx.coroutines.flow.channelFlow
 import kotlinx.coroutines.flow.flow
 import kotlinx.coroutines.flow.flowOn
 import org.mjdev.tvapp.BuildConfig
 import org.mjdev.tvapp.activity.MainActivity
-import org.mjdev.tvapp.data.local.Country
 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.viewmodel.BaseViewModel
-import org.mjdev.tvapp.data.local.Movie
 import org.mjdev.tvapp.database.DAO
-import org.mjdev.tvlib.database.extensions.ObjectBoxExt.findById
 import org.mjdev.tvlib.extensions.ListExt.asMap
 import org.mjdev.tvlib.extensions.ListExt.takeIf
-import org.mjdev.tvlib.helpers.apps.appsManager
 import org.mjdev.tvlib.interfaces.ItemWithImage.Companion.hasImage
 import javax.inject.Inject
 
 @HiltViewModel
-class MainViewModel @Inject constructor(
-    @ApplicationContext
-    context: Context
-) : BaseViewModel() {
+class MainViewModel @Inject constructor() : BaseViewModel() {
 
     @Suppress("PrivatePropertyName")
     private val ITEMS_FROM_CATEGORY = 3
 
-    private val excludedActivity = ComponentName(
-        BuildConfig.APPLICATION_ID,
-        MainActivity::class.java.name
-    )
+//    private val excludedActivity = ComponentName(
+//        BuildConfig.APPLICATION_ID,
+//        MainActivity::class.java.name
+//    )
+
+    @ApplicationContext
+    @Inject
+    lateinit var context: Context
 
     @Inject
     lateinit var dao: DAO
@@ -59,56 +55,56 @@ class MainViewModel @Inject constructor(
     @Inject
     lateinit var localPhotoCursor: PhotoCursor
 
-    // todo : move to cursor
-    val apps = appsManager(context, excludedActivity).stateInViewModel()
+//    // todo : move to cursor
+//    val apps
+//        get() = appsManager(context, excludedActivity).stateInViewModel()
 
     // todo : move to cursor
-    val messages = channelFlow {
-        send(dao.messagesDao.all)
-    }.flowOn(Dispatchers.IO).stateInViewModel()
+    val messages
+        get() = channelFlow {
+            send(dao.messagesDao.all)
+        }.flowOn(Dispatchers.IO).stateInViewModel()
 
     // todo latest items, anr removal
     // todo : move to cursor
-    val featuredMovieList = flow {
-        mutableListOf<Any?>().apply {
-            addAll(localAudioCursor.takeIf(ITEMS_FROM_CATEGORY) { hasImage })
-            addAll(localVideoCursor.takeIf(ITEMS_FROM_CATEGORY) { hasImage })
-            addAll(localPhotoCursor.takeIf(ITEMS_FROM_CATEGORY) { hasImage })
-            addAll(dao.movieDao.all.takeIf(ITEMS_FROM_CATEGORY) { hasImage })
-        }.also { result ->
-            emit(result)
-        }
-    }.flowOn(Dispatchers.IO).stateInViewModel()
+    val featuredMovieList
+        get() = flow {
+            mutableListOf<Any?>().apply {
+                addAll(localAudioCursor.takeIf(ITEMS_FROM_CATEGORY) { hasImage })
+                addAll(localVideoCursor.takeIf(ITEMS_FROM_CATEGORY) { hasImage })
+                addAll(localPhotoCursor.takeIf(ITEMS_FROM_CATEGORY) { hasImage })
+                addAll(dao.movieDao.all.takeIf(ITEMS_FROM_CATEGORY) { hasImage })
+            }.also { result ->
+                emit(result)
+            }
+        }.flowOn(Dispatchers.IO).stateInViewModel()
 
     // todo improve anr
     // todo : move to cursor
-    val movieList = flow {
-        dao.movieDao.all.sortedBy { m ->
-            m.category
-        }.asMap { m ->
-            Pair(m.category ?: "-", m)
-        }.also { map ->
-            emit(map)
-        }
-    }.flowOn(Dispatchers.IO).stateInViewModel()
+    val movieList
+        get() = flow {
+            dao.movieDao.all.sortedBy { m ->
+                m.category
+            }.asMap { m ->
+                Pair(m.category ?: "-", m)
+            }.also { map ->
+                emit(map)
+            }
+        }.flowOn(Dispatchers.IO).stateInViewModel()
 
     // todo in sync adapter
     // todo : move to cursor
-    val countryList: StateFlow<List<Country>> = flow {
-        emit(dao.countriesDao.all)
-    }.flowOn(Dispatchers.IO).stateInViewModel()
-
-    fun findMovie(
-        id: Long?,
-        findBlock: (movie: Movie?) -> Unit
-    ): Movie? = dao.movieDao.findById(id).apply(findBlock)
+    val countryList
+        get() = flow {
+            emit(dao.countriesDao.all)
+        }.flowOn(Dispatchers.IO).stateInViewModel()
 
     companion object {
 
         @Suppress("unused")
         fun mock(
             context: Context
-        ): MainViewModel = MainViewModel(context).apply {
+        ): MainViewModel = MainViewModel().apply {
             dao = DAO(context)
             localAudioCursor = AudioCursor(context)
             localVideoCursor = VideoCursor(context)

+ 1 - 0
app/src/main/kotlin/org/mjdev/tvapp/widget/MainAppWidget.kt

@@ -71,4 +71,5 @@ class MainAppWidget : GlanceAppWidget() {
             }
         }
     }
+
 }

+ 2 - 2
app/src/main/kotlin/org/mjdev/tvapp/widget/MyAppWidgetReceiver.kt

@@ -1,5 +1,5 @@
 /*
- *  Copyright (c) Milan Jurkulák 2023.
+ *  Copyright (c) Milan Jurkulák 2024.
  *  Contact:
  *  e: mimoccc@gmail.com
  *  e: mj@mjdev.org
@@ -13,4 +13,4 @@ import androidx.glance.appwidget.GlanceAppWidgetReceiver
 
 class MyAppWidgetReceiver : GlanceAppWidgetReceiver() {
     override val glanceAppWidget: GlanceAppWidget = MainAppWidget()
-}
+}

+ 2 - 2
app/src/main/kotlin/org/mjdev/tvapp/widget/PlayPauseAction.kt

@@ -1,5 +1,5 @@
 /*
- *  Copyright (c) Milan Jurkulák 2023.
+ *  Copyright (c) Milan Jurkulák 2024.
  *  Contact:
  *  e: mimoccc@gmail.com
  *  e: mj@mjdev.org
@@ -21,4 +21,4 @@ class PlayPauseAction : ActionCallback {
     ) {
         // todo implement
     }
-}
+}

+ 2 - 2
app/src/main/kotlin/org/mjdev/tvapp/widget/RefreshAction.kt

@@ -1,5 +1,5 @@
 /*
- *  Copyright (c) Milan Jurkulák 2023.
+ *  Copyright (c) Milan Jurkulák 2024.
  *  Contact:
  *  e: mimoccc@gmail.com
  *  e: mj@mjdev.org
@@ -23,4 +23,4 @@ class RefreshAction : ActionCallback {
     ) {
         MainAppWidget().update(context, glanceId)
     }
-}
+}

BIN
app/src/main/res/drawable/background.png


+ 22 - 14
build.gradle.kts

@@ -1,18 +1,21 @@
 /*
- * Copyright (c) Milan Jurkulák 2023.
- * Contact:
- * e: mimoccc@gmail.com
- * e: mj@mjdev.org
- * w: https://mjdev.org
+ *  Copyright (c) Milan Jurkulák 2024.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
  */
 
 plugins {
     id("com.google.dagger.hilt.android") version "2.47" apply false
-    id("com.google.devtools.ksp") version "1.9.0-1.0.11" apply false
+    id("com.google.devtools.ksp") version "1.9.22-1.0.17" apply false
     id("org.jlleitschuh.gradle.ktlint") version "11.3.2" apply true
-    id("org.jetbrains.dokka") version "1.8.10" apply true
+//    id("com.android.library") version "8.2.2" apply false
+//    id("org.jetbrains.kotlin.android") version "1.9.22" apply false
     // todo ktorfit https://foso.github.io/Ktorfit/
 //    id("de.jensklingenberg.ktorfit") version "1.10.2" apply true
+//    ktlint()
+//    detekt()
 }
 
 buildscript {
@@ -25,28 +28,33 @@ buildscript {
     }
 
     dependencies {
-        classpath("com.android.tools.build:gradle:8.2.0")
-        classpath("com.android.tools.build:gradle-api:8.2.0")
-        classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.0")
-        classpath("com.android.tools:r8:8.2.33")
+        classpath("com.android.tools.build:gradle:8.2.2")
+        classpath("com.android.tools.build:gradle-api:8.2.2")
+        classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.22")
+        classpath("com.android.tools:r8:8.2.42")
         classpath("io.objectbox:objectbox-gradle-plugin:3.7.1")
-//        classpath("com.github.ben-manes:gradle-versions-plugin:0.42.0")
+        classpath("org.jetbrains.dokka:dokka-base:1.9.10")
+        classpath("org.jetbrains.dokka:dokka-gradle-plugin:1.8.10")
+        classpath("org.jetbrains.dokka:android-documentation-plugin:1.9.10")
+        classpath("io.gitlab.arturbosch.detekt:detekt-gradle-plugin:1.23.4")
+        classpath("app.cash.paparazzi:paparazzi-gradle-plugin:1.3.1")
 //        classpath("com.diffplug.spotless:spotless-plugin-gradle:6.4.2")
-//        classpath("io.gitlab.arturbosch.detekt:detekt-gradle-plugin:1.20.0-RC2")
 //        classpath("com.bmuschko:gradle-docker-plugin:9.4.0")
 //        classpath("com.google.android.libraries.mapsplatform.secrets-gradle-plugin:secrets-gradle-plugin:2.0.1")
 //        classpath("com.newrelic.agent.android:agent-gradle-plugin:5.9.0")
-//        classpath ("app.cash.paparazzi:paparazzi-gradle-plugin:1.3.1")
     }
 }
 
 allprojects {
     apply(plugin = "org.jlleitschuh.gradle.ktlint")
     apply(plugin = "org.jetbrains.dokka")
+//    apply(plugin = "app.cash.paparazzi")
 }
 
 subprojects {
+    apply(plugin = "org.jlleitschuh.gradle.ktlint")
     apply(plugin = "org.jetbrains.dokka")
+//    apply(plugin = "app.cash.paparazzi")
 }
 
 tasks.register<Delete>("clean") {

+ 34 - 31
buildSrc/build.gradle.kts

@@ -19,55 +19,58 @@ repositories {
 
 plugins {
     `kotlin-dsl`
-    `java-gradle-plugin`
-//    id("org.jetbrains.dokka") version "1.8.10" apply false
-//    id("io.gitlab.arturbosch.detekt")
-//    id("com.github.ben-manes.versions")
-//    id("com.bmuschko.docker-remote-api")
 }
 
 dependencies {
     implementation(gradleApi())
     implementation(gradleKotlinDsl())
-    implementation("com.android.tools.build:gradle-api:8.2.0")
-    implementation("com.android.tools.build:gradle:8.2.0")
-    implementation("org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.0")
+//    implementation(kotlin("reflect", "1.8.21"))
+    implementation("com.android.tools.build:gradle-api:8.2.2")
+    implementation("com.android.tools.build:gradle:8.2.2")
+    implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.21")
+    implementation("org.jetbrains.kotlin:kotlin-reflect:1.9.22")
+    implementation("org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.22")
+    implementation("io.gitlab.arturbosch.detekt:detekt-gradle-plugin:1.23.4")
+    implementation("io.gitlab.arturbosch.detekt:detekt-formatting:1.23.4")
     implementation("com.squareup:javapoet:1.13.0")
+    implementation("com.squareup:kotlinpoet:1.13.2")
+    implementation("org.jetbrains.dokka:dokka-android-gradle-plugin:0.9.18")
+    implementation("org.jetbrains.dokka:gfm-plugin:1.8.10")
+    implementation("org.jetbrains.dokka:android-documentation-plugin:1.9.10")
+    implementation("org.eclipse.jgit:org.eclipse.jgit:4.8.0.201706111038-r")
+    implementation("com.github.ben-manes:gradle-versions-plugin:0.42.0")
+    implementation("com.squareup.okhttp3:okhttp:5.0.0-alpha.12")
+    implementation("com.squareup.okhttp3:logging-interceptor:5.0.0-alpha.12")
+    implementation("com.squareup.moshi:moshi-kotlin:1.12.0")
+    implementation ("org.mockito.kotlin:mockito-kotlin:5.2.1")
+    implementation ("junit:junit:4.13.2")
 //    implementation("com.bmuschko:gradle-docker-plugin:9.4.0")
-//    implementation("org.jetbrains.dokka:dokka-android-gradle-plugin:0.9.18")
-//    implementation(kotlin("reflect", "1.8.21"))
 //    implementation("com.soywiz.korlibs.korim:korim-jvm:4.0.2")
 //    implementation("com.github.mukeshsolanki:photofilter:2.0.2")
 //    implementation("gradle.plugin.com.autonomousapps:dependency-analysis-gradle-plugin:0.10.0")
 //    implementation("com.google.apis:google-api-services-androidpublisher:v3-rev41-1.25.0")
-//    implementation("com.squareup:javapoet:1.13.0")
-//    implementation("com.squareup:kotlinpoet:1.13.2")
-//    implementation("com.squareup.okhttp3:okhttp:5.0.0-alpha.2")
-//    implementation("com.squareup.okhttp3:logging-interceptor:5.0.0-alpha.2")
 //    implementation("org.ini4j:ini4j:0.5.4")
 //    implementation("org.jsoup:jsoup:1.15.4")
 //    implementation("com.google.code.gson:gson:2.10.1")
 //    implementation("net.lingala.zip4j:zip4j:1.2.4")
-//    implementation("org.eclipse.jgit:org.eclipse.jgit:4.8.0.201706111038-r")
 }
 
 gradlePlugin {
     plugins {
-        register("MainAppPlugin") {
-            id = "MainAppPlugin"
-            displayName = "MainAppPlugin"
-            description = "Common app plugin to handle all stuffs needed."
-            implementationClass = "org.mjdev.gradle.plugin.MainAppPlugin"
+
+        register("AppPlugin") {
+            id = "AppPlugin"
+            displayName = "AppPlugin"
+            description = "Common application plugin to handle all stuffs needed."
+            implementationClass = "org.mjdev.gradle.plugin.AppPlugin"
         }
-//        register("DependencyUpdatePlugin") {
-//            id = "DependencyUpdatePlugin"
-//            displayName = "DependencyUpdatePlugin"
-//            description = "Dependency update plugin"
-//            implementationClass = "org.mjdev.gradle.plugin.DependencyUpdatePlugin"
-//        }
-//        register("AndroidCoreLibraryPlugin") {
-//            id = "AndroidCoreLibraryPlugin"
-//            implementationClass = "commons.AndroidCoreLibraryPlugin"
-//        }
+
+        register("LibPlugin") {
+            id = "LibPlugin"
+            displayName = "LibPlugin"
+            description = "Common library plugin to handle all stuffs needed."
+            implementationClass = "org.mjdev.gradle.plugin.LibPlugin"
+        }
+
     }
-}
+}

+ 34 - 0
buildSrc/src/main/kotlin/org/mjdev/gradle/base/BaseTask.kt

@@ -0,0 +1,34 @@
+/*
+ *  Copyright (c) Milan Jurkulák 2024.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */
+
+@file:Suppress("DEPRECATION")
+
+package org.mjdev.gradle.base
+
+import com.android.build.gradle.api.BaseVariant
+import org.gradle.api.DefaultTask
+import org.gradle.api.logging.Logging
+import org.gradle.api.tasks.Internal
+import org.mjdev.gradle.extensions.capitalize
+
+open class BaseTask : DefaultTask() {
+
+    private val log by lazy {
+        Logging.getLogger(this::class.java)
+    }
+
+    @Internal
+    lateinit var variant: BaseVariant
+
+    val variantName: String
+        @Internal
+        get() = variant.name.capitalize()
+
+    fun println(message: String) = log.lifecycle(message)
+
+}

+ 48 - 0
buildSrc/src/main/kotlin/org/mjdev/gradle/base/PluginBase.kt

@@ -0,0 +1,48 @@
+/*
+ *  Copyright (c) Milan Jurkulák 2024.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */
+
+package org.mjdev.gradle.base
+
+import com.android.build.api.variant.AndroidComponentsExtension
+import com.android.build.gradle.TestedExtension
+import org.gradle.api.Plugin
+import org.gradle.api.Project
+import org.mjdev.gradle.extensions.extension
+
+@Suppress("MemberVisibilityCanBePrivate", "DEPRECATION")
+open class PluginBase<E : PluginExtension, F : TestedExtension>(
+    val extensionClass: Class<E>,
+    val init: PluginScope<E, F>.() -> Unit
+) : Plugin<Project> {
+
+    lateinit var scope: PluginScope<E, F>
+    lateinit var project: Project
+
+    val extensionName :String
+        get() = extensionClass.newInstance().name
+
+    @Suppress("UNCHECKED_CAST")
+    val params: E
+        get() = getExtension(extensionName, extensionClass) as E
+
+    override fun apply(project: Project) {
+        this.project = project
+        createExtension(extensionName, extensionClass)
+        scope = PluginScope(this@PluginBase)
+    }
+
+    val androidComponents: AndroidComponentsExtension<*, *, *>
+        get() = project.extensions.getByType(AndroidComponentsExtension::class.java)
+
+    fun createExtension(name: String, cls: Class<*>) =
+        project.extension(name, cls, true)
+
+
+    fun getExtension(name: String, cls: Class<*>) =
+        project.extension(name, cls, false)
+}

+ 11 - 0
buildSrc/src/main/kotlin/org/mjdev/gradle/base/PluginExtension.kt

@@ -0,0 +1,11 @@
+/*
+ *  Copyright (c) Milan Jurkulák 2024.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */
+
+package org.mjdev.gradle.base
+
+open class PluginExtension(val name: String)

+ 321 - 0
buildSrc/src/main/kotlin/org/mjdev/gradle/base/PluginScope.kt

@@ -0,0 +1,321 @@
+/*
+ *  Copyright (c) Milan Jurkulák 2024.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */
+
+package org.mjdev.gradle.base
+
+import com.android.build.api.dsl.CommonExtension
+import com.android.build.api.variant.AndroidComponentsExtension
+import com.android.build.api.variant.Variant
+import com.android.build.gradle.TestedExtension
+import com.android.build.gradle.internal.api.BaseVariantImpl
+import org.gradle.api.Action
+import org.gradle.api.GradleException
+import org.gradle.api.JavaVersion
+import org.gradle.api.Plugin
+import org.gradle.api.Project
+import org.gradle.api.artifacts.Configuration
+import org.gradle.api.artifacts.dsl.DependencyHandler
+import org.gradle.api.artifacts.dsl.RepositoryHandler
+import org.gradle.api.file.ConfigurableFileCollection
+import org.gradle.api.invocation.Gradle
+import org.gradle.api.plugins.AppliedPlugin
+import org.gradle.api.plugins.PluginManager
+import org.gradle.internal.logging.text.StyledTextOutput
+import org.gradle.internal.logging.text.StyledTextOutputFactory
+import org.gradle.kotlin.dsl.extra
+import org.gradle.kotlin.dsl.repositories
+import org.gradle.kotlin.dsl.support.serviceOf
+import org.mjdev.gradle.extensions.extension
+import org.mjdev.gradle.extensions.file
+import org.mjdev.gradle.extensions.versionCode
+import org.mjdev.gradle.extensions.versionName
+import java.io.File
+import java.io.FileInputStream
+import java.io.PrintWriter
+import java.io.StringWriter
+import java.util.Properties
+
+@Suppress("MemberVisibilityCanBePrivate", "unused")
+class PluginScope<E : PluginExtension, F : TestedExtension>(
+    val plugin: PluginBase<E, F>,
+    val project: Project = plugin.project,
+) {
+    val taskScope: TaskScope<E, F> = TaskScope(project, this)
+
+    val testBlocks = mutableListOf<PluginScope<E, F>.() -> Unit>()
+    val androidBlocks = mutableListOf<F.() -> Unit>()
+    val pluginsBlocks = mutableListOf<PluginManager.() -> Unit>()
+    val repoBlocks = mutableListOf<RepositoryHandler.() -> Unit>()
+    val dependencyBlocks = mutableListOf<DependencyHandler.() -> Unit>()
+
+    @Suppress("UNCHECKED_CAST")
+    val android: F
+        get() = project.extensions.findByName("android") as F
+
+    val androidComponents: AndroidComponentsExtension<*, *, *>
+        get() = project.extensions.getByType(AndroidComponentsExtension::class.java)
+
+    val projectDir: File
+        get() = project.projectDir
+
+    val rootProject: Project
+        get() = project.rootProject
+
+    val rootProjectDir: File
+        get() = project.rootDir
+
+    val extra
+        get() = project.extra
+
+    val versionName
+        get() = project.versionName
+
+    val versionCode
+        get() = project.versionCode
+
+    val params
+        get() = plugin.params
+
+    val gradle: Gradle
+        get() = project.gradle
+
+    init {
+        plugin.init(this@PluginScope)
+        runPlugins()
+        runRepositories()
+        runDependencies()
+        afterEvaluate {
+            runAndroids()
+            runTests()
+        }
+    }
+
+    private fun runPlugins() {
+        project.pluginManager.also { pm ->
+            pluginsBlocks.forEach { it.invoke(pm) }
+        }
+    }
+
+    private fun runRepositories() {
+        project.repositories.also { repos ->
+            repoBlocks.forEach { it.invoke(repos) }
+        }
+    }
+
+    private fun runDependencies() {
+        project.dependencies.also { repos ->
+            dependencyBlocks.forEach { it.invoke(repos) }
+        }
+    }
+
+    fun plugins(block: PluginManager.() -> Unit) {
+        pluginsBlocks.add(block)
+    }
+
+    fun repositories(block: RepositoryHandler.() -> Unit) {
+        repoBlocks.add(block)
+    }
+
+    fun dependencies(block: DependencyHandler.() -> Unit) {
+        dependencyBlocks.add(block)
+    }
+
+    fun tasks(
+        block: TaskScope<E, F>.() -> Unit
+    ) = project.afterEvaluate { block.invoke(taskScope) }
+
+    @Suppress("UnstableApiUsage")
+    fun finalizeDsl(block: CommonExtension<*, *, *, *, *>.() -> Unit) {
+        androidComponents.finalizeDsl { android ->
+            block(android)
+        }
+    }
+
+    fun variants(block:Variant.() -> Unit) {
+        androidComponents.onVariants {
+            block(it)
+        }
+    }
+
+    inline fun <reified T> createExtension(name: String) =
+        project.extension(name, T::class.java, true)
+
+    fun configuration(
+        name: String
+    ): Configuration? = with(project.configurations) {
+        findByName(name) ?: create(name)
+    }
+
+    fun rootFileExists(path: String): Boolean =
+        rootProject.file(path).exists()
+
+    fun file(path: Any): File =
+        project.file(path)
+
+    fun rootFile(path: String): File =
+        project.rootProject.file(path)
+
+    fun files(vararg paths: Any): ConfigurableFileCollection =
+        project.files(paths)
+
+    fun rootFiles(vararg paths: Any): ConfigurableFileCollection =
+        project.rootProject.files(paths)
+
+    fun allprojects(action: Action<in Project>) =
+        project.allprojects(action)
+
+    @Suppress("HasPlatformType")
+    fun println(
+        message: String
+    ) = gradle.serviceOf<StyledTextOutputFactory>()
+        .create("out")
+        .style(StyledTextOutput.Style.Failure)
+        .println("> $message")
+
+    @Suppress("HasPlatformType")
+    fun printErr(
+        error: Throwable,
+        includeStackTrace: Boolean = true
+    ) = gradle.serviceOf<StyledTextOutputFactory>()
+        .create("out")
+        .style(StyledTextOutput.Style.Failure).apply {
+            println("> ${plugin::class.simpleName} ${error::class.simpleName} : ${error.message}")
+            if (includeStackTrace) {
+                val writer = StringWriter()
+                val printWriter = PrintWriter(writer)
+                error.printStackTrace(printWriter)
+                printWriter.flush()
+                val stackTrace = writer.toString()
+                stackTrace.split("\n").forEach { line ->
+                    println("> ${plugin::class.simpleName} ${error::class.simpleName} : $line")
+                }
+            }
+        }
+
+    fun afterEvaluate(
+        block: PluginScope<E, F>.() -> Unit
+    ) {
+        project.afterEvaluate {
+            block(this@PluginScope)
+        }
+    }
+
+    fun findPlugin(
+        name: String
+    ): AppliedPlugin? = try {
+        project.pluginManager.findPlugin(name)
+    } catch (e: Exception) {
+        null
+    }
+
+    fun applyPlugin(
+        name: String
+    ) = runCatching {
+        val pluginName = findPlugin(name)?.name
+        println("Applying plugin: ${pluginName ?: name}")
+        project.pluginManager.apply(name)
+    }
+
+    fun <T : Plugin<*>> applyPlugin(
+        cls: Class<T>
+    ) = runCatching {
+        val name = cls.simpleName.replace("_Decorated", "")
+        println("Applying plugin: $name")
+        project.pluginManager.apply(cls)
+    }
+
+    @Suppress("UNNECESSARY_SAFE_CALL")
+    inline fun <reified T> configureExtension(crossinline block: T.() -> Unit) {
+        val name = T::class.simpleName?.replace("_Decorated", "")
+        runCatching {
+            println("Configuring extension: $name")
+            project.extensions.getByType(T::class.java)?.apply {
+                block(this)
+            } ?: throw (GradleException("Extension $name not found"))
+        }
+    }
+
+    fun test(block: PluginScope<E, F>.() -> Unit) {
+        testBlocks.add(block)
+    }
+
+    fun android(block: F.() -> Unit) {
+        androidBlocks.add(block)
+    }
+
+    fun loadPropertiesFile(path: String) = runCatching {
+        val file = rootProject.file(path)
+        val props = Properties()
+        props.load(FileInputStream(file))
+        props.forEach { prop ->
+            rootProject.extra.set(prop.key.toString(), prop.value.toString())
+        }
+    }
+
+    fun loadKeyStoreProperties(
+        path: String,
+        block: (keyAlias: String, keyPassword: String, storeFile: File, storePassword: String) -> Unit
+    ) {
+        val keystorePropertiesFile = rootProject.file(path)
+        val keystoreProperties = Properties()
+        if (keystorePropertiesFile.exists()) {
+            keystoreProperties.load(FileInputStream(keystorePropertiesFile))
+        } else {
+            keystoreProperties["keyAlias"] = System.getenv("KEYSTORE_KEY_ALIAS") ?: ""
+            keystoreProperties["keyPassword"] = System.getenv("KEYSTORE_KEY_PASSWORD") ?: ""
+            keystoreProperties["storePassword"] = System.getenv("KEYSTORE_STORE_PASSWORD") ?: ""
+            keystoreProperties["storeFile"] = System.getenv("KEYSTORE_FILE") ?: ""
+        }
+        block.invoke(
+            keystoreProperties["keyAlias"] as String? ?: "",
+            keystoreProperties["keyPassword"] as? String? ?: "",
+            File(projectDir, keystoreProperties["storeFile"] as? String? ?: ""),
+            keystoreProperties["storePassword"] as? String? ?: ""
+        )
+    }
+
+    fun params(
+        block: E.() -> Unit
+    ) = runCatching {
+        block.invoke(params)
+    }
+
+    inline fun <reified T, R> withNonNull(
+        receiver: T?,
+        crossinline block: T.() -> R
+    ) = runCatching {
+        receiver?.let {
+            with(it, block)
+        } ?: throw (GradleException("${T::class.simpleName} is null"))
+    }
+
+    fun runTests() {
+        testBlocks.forEach { testBlock ->
+            runCatching { testBlock(this) }
+        }
+    }
+
+    fun runAndroids() {
+        androidBlocks.forEach { androidBlock ->
+            runCatching {
+                android.let {
+                    try {
+                        androidBlock(it)
+                    } catch (e: Exception) {
+                        printErr(e)
+                    }
+                } ?: throw (GradleException("No android extension found"))
+            }
+        }
+    }
+
+    companion object {
+        const val SIGNING_CONFIG_NAME = "Any"
+    }
+
+}

+ 134 - 0
buildSrc/src/main/kotlin/org/mjdev/gradle/base/TaskScope.kt

@@ -0,0 +1,134 @@
+/*
+ *  Copyright (c) Milan Jurkulák 2024.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */
+
+@file:Suppress("DEPRECATION")
+
+package org.mjdev.gradle.base
+
+import com.android.build.gradle.TestedExtension
+import com.android.build.gradle.api.BaseVariant
+import org.gradle.api.Action
+import org.gradle.api.GradleException
+import org.gradle.api.Project
+import org.gradle.api.Task
+import org.gradle.api.tasks.TaskContainer
+import org.mjdev.gradle.extensions.byName
+import org.mjdev.gradle.extensions.byType
+import org.mjdev.gradle.extensions.assembleTasks
+import org.mjdev.gradle.extensions.capitalize
+import org.mjdev.gradle.extensions.registerTask
+
+@Suppress("MemberVisibilityCanBePrivate", "unused", "UnusedReceiverParameter")
+class TaskScope<E : PluginExtension, F : TestedExtension>(
+    val project: Project,
+    val pluginScope: PluginScope<E, F>
+) {
+
+    val tasks: TaskContainer
+        get() = project.tasks
+
+    val assembleTasks: List<Task>
+        get() = project.assembleTasks
+
+    val cleanTask: Task?
+        get() = byName("clean")
+
+    val preBuildTask: Task?
+        get() = byName("preBuild")
+
+    val assembleDebugTask: Task?
+        get() = byName("assembleDebug")
+
+    val ktlintFormatTask: Task?
+        get() = byName("ktlintFormat")
+
+    inline fun <reified T : Task> byName(name: String): T? =
+        tasks.byName(name) as T?
+
+    inline fun <reified T : Task> byType(): T? =
+        tasks.byType() as T?
+
+    inline fun <reified T : Task> register(
+        name: String = T::class.java.simpleName.replaceFirstChar { char ->
+            char.lowercase()
+        }.replace("Task", ""),
+        configurationAction: Action<T> = Action<T> {}
+    ): List<T> = project.registerTask<T>(name, configurationAction)
+
+    fun  Task.task(name: String, block: Task.() -> Unit) {
+        tasks.getByName(name).also { task ->
+            block.invoke(task)
+        }
+    }
+
+    fun  Task.task(
+        nameFormat: String,
+        variant: BaseVariant,
+        block: Task.() -> Unit
+    ) {
+        tasks.getByName(String.format(nameFormat, variant.name.capitalize())).also { task ->
+            block.invoke(task)
+        }
+    }
+
+    fun Task.task(name: String): Task = tasks.getByName(name)
+
+    fun Task.task(
+        nameFormat: String,
+        variant: BaseVariant
+    ): Task = tasks.getByName(String.format(nameFormat, variant.name.capitalize()))
+
+
+    inline fun <reified T> printErr(
+        message: String,
+        includeStackTrace: Boolean = true
+    ): T? = printErr(GradleException(message), includeStackTrace)
+
+    inline fun <reified T> printErr(
+        error: Throwable,
+        includeStackTrace: Boolean = true
+    ): T? {
+        pluginScope.printErr(error, includeStackTrace)
+        return null
+    }
+
+    fun println(
+        message: String
+    ) {
+        pluginScope.println(message)
+    }
+
+    fun assembleTasks(block: Task.() -> Unit) = assembleTasks.forEach { task ->
+        block.invoke(task)
+    }
+
+    fun runSafe(
+        block: () -> Unit
+    ) {
+        try {
+            block()
+        } catch (t: Throwable) {
+            printErr(t)
+        }
+    }
+
+    companion object {
+        const val TASK_ASSEMBLE_RELEASE = "assembleRelease"
+        const val TASK_ASSEMBLE_DEBUG = "assembleDebug"
+        const val TASK_ASSEMBLE_MINIFIED = "assembleMinified"
+        const val TASK_ASSEMBLE_MOCK = "assembleMock"
+
+        val ASSEMBLE_TASKS = listOf(
+            TASK_ASSEMBLE_DEBUG,
+            TASK_ASSEMBLE_RELEASE,
+            TASK_ASSEMBLE_MOCK,
+            TASK_ASSEMBLE_MINIFIED,
+        )
+    }
+
+}

+ 15 - 8
buildSrc/src/main/kotlin/org/mjdev/gradle/dependency/DependencyHandler.kt

@@ -47,8 +47,8 @@ fun DependencyHandler.kapt(dependencyNotation: String): Dependency? =
 fun DependencyHandler.ksp(dependencyNotation: String): Dependency? =
     add("ksp", dependencyNotation)
 
-fun DependencyHandler.dokkaPlugin(dependencyNotation: String): Dependency? =
-    add("dokkaPlugin", dependencyNotation)
+//fun DependencyHandler.dokkaPlugin(dependencyNotation: String): Dependency? =
+//    add("dokkaPlugin", dependencyNotation)
 
 fun DependencyHandler.mockDependencies() = arrayOf(
     implementation("io.mockk:mockk:1.9")
@@ -73,6 +73,7 @@ fun DependencyHandler.moshiDependencies() = arrayOf(
     ksp("com.squareup.moshi:moshi-kotlin-codegen:1.15.0"),
 )
 
+// r2.19.1
 fun DependencyHandler.exoPlayerDependencies() = arrayOf(
     implementation("androidx.media3:media3-exoplayer:1.2.0"),
     implementation("androidx.media3:media3-exoplayer-dash:1.2.0"),
@@ -120,7 +121,7 @@ fun DependencyHandler.baseDependencies() = arrayOf(
     // startups
 //    implementation("androidx.startup:startup-runtime:1.1.1"),
     // dokka
-    dokkaPlugin("org.jetbrains.dokka:android-documentation-plugin:1.9.10"),
+//    dokkaPlugin("org.jetbrains.dokka:android-documentation-plugin:1.9.10"),
     // core
     implementation("androidx.core:core-ktx:1.12.0"),
     implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3"),
@@ -149,6 +150,8 @@ fun DependencyHandler.glideDependencies() = arrayOf(
     implementation("com.github.skydoves:landscapist-placeholder:2.2.13"),
     // glide annotations
     ksp("com.github.bumptech.glide:ksp:4.16.0"),
+    // exif
+    implementation("androidx.exifinterface:exifinterface:1.3.7"),
 )
 
 fun DependencyHandler.daggerDependencies() = arrayOf(
@@ -214,20 +217,18 @@ fun DependencyHandler.anotherDependencies() = arrayOf(
     implementation("androidx.palette:palette-ktx:1.0.0"),
     // lottie
     implementation("com.airbnb.android:lottie-compose:6.2.0"),
-    // exif info
-    implementation("androidx.exifinterface:exifinterface:1.3.7"),
     // jsoup
-    implementation("org.jsoup:jsoup:1.17.1"),
+    implementation("org.jsoup:jsoup:1.17.2"),
     // dm
     implementation("com.dailymotion.dailymotion-sdk-android:sdk:0.2.12"),
     // dynamic theme
     implementation("com.google.android.material:material:1.11.0"),
     // scrape
-    implementation("net.sourceforge.htmlunit:htmlunit-android:2.67.0"),
+//    implementation("net.sourceforge.htmlunit:htmlunit-android:2.67.0"),
 //    implementation("org.htmlunit:htmlunit3-android:3.7.0"),
 //    implementation("org.htmlunit:htmlunit:3.9.0"),
     // flip card
-    implementation("com.wajahatkarim:flippable:1.5.4"),
+//    implementation("com.wajahatkarim:flippable:1.5.4"),
     // ui controller
 //    implementation("com.google.accompanist:accompanist-systemuicontroller:0.23.1"),
     // insets
@@ -240,6 +241,12 @@ fun DependencyHandler.anotherDependencies() = arrayOf(
     // ...
     // a.i.
 //    implementation("com.google.ai.client.generativeai:generativeai:0.1.1"),
+    // kotlin soup
+//    implementation("com.mohamedrejeb.ksoup:ksoup-html:0.3.0"),
+//    implementation("com.mohamedrejeb.ksoup:ksoup-entities:0.3.0"),
+//    implementation("io.ktor:ktor-server-core:2.3.7"),
+//    implementation("io.ktor:ktor-server-cio:2.3.7"),
+//    implementation("com.github.bjoernpetersen:m3u-parser:1.4.0")
 )
 
 fun DependencyHandler.mjdevTvLib() = arrayOf(

+ 17 - 0
buildSrc/src/main/kotlin/org/mjdev/gradle/extensions/DateExt.kt

@@ -0,0 +1,17 @@
+/*
+ *  Copyright (c) Milan Jurkulák 2024.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */
+
+package org.mjdev.gradle.extensions
+
+import java.text.SimpleDateFormat
+import java.util.Date
+
+@Suppress("SimpleDateFormat")
+fun Date.toDateString(
+    format: String = "dd.MM.yyyy"
+): String = SimpleDateFormat(format).format(this)

+ 20 - 0
buildSrc/src/main/kotlin/org/mjdev/gradle/extensions/ExtFileFilter.kt

@@ -0,0 +1,20 @@
+/*
+ *  Copyright (c) Milan Jurkulák 2024.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */
+
+package org.mjdev.gradle.extensions
+
+import java.io.File
+import java.io.FilenameFilter
+
+class ExtFileFilter(extension: String?) : FilenameFilter {
+    var extension: String = String.format(".%s", extension)
+
+    override fun accept(dir: File?, fileName: String?): Boolean {
+        return fileName?.endsWith(extension) ?: false
+    }
+}

+ 105 - 0
buildSrc/src/main/kotlin/org/mjdev/gradle/extensions/File.kt

@@ -0,0 +1,105 @@
+/*
+ *  Copyright (c) Milan Jurkulák 2024.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */
+
+@file:Suppress("unused")
+
+package org.mjdev.gradle.extensions
+
+import org.gradle.internal.impldep.org.apache.commons.io.FileUtils
+import java.io.File
+import java.nio.file.Files
+import java.nio.file.LinkOption
+import java.nio.file.Path
+import java.nio.file.Paths
+import java.nio.file.attribute.BasicFileAttributeView
+import java.nio.file.attribute.BasicFileAttributes
+import java.time.Instant
+import java.time.LocalDateTime
+import java.time.ZoneId
+
+fun File.file(
+    name: String
+): File = File(this, name)
+
+fun String.asFile() = File(this)
+
+fun File.writeText(
+    text: String
+) {
+    parentFile.mkdirs()
+    writeText(text, charset("UTF-8"))
+}
+
+fun File?.getCreateTime(): LocalDateTime? {
+    return this?.let {
+        if (exists()) {
+            try {
+                val path: Path = Paths.get(this.path)
+                val basicfile: BasicFileAttributeView = Files.getFileAttributeView(
+                    path,
+                    BasicFileAttributeView::class.java,
+                    LinkOption.NOFOLLOW_LINKS
+                )
+                val attr: BasicFileAttributes = basicfile.readAttributes()
+                val date: Long = attr.creationTime().toMillis()
+                val instant: Instant = Instant.ofEpochMilli(date)
+                LocalDateTime.ofInstant(instant, ZoneId.systemDefault())
+            } catch (e: Exception) {
+                null
+            }
+        } else
+            null
+    } ?: LocalDateTime.MIN
+}
+
+fun File.moveTo(newFile: File) {
+    if (exists()) {
+        if (newFile.exists()) newFile.delete()
+        this.copyTo(newFile)
+        this.delete()
+    }
+}
+
+val File.dirSize: Int
+    get() = if (!isDirectory) 0 else listFiles()?.size ?: 0
+
+val File.dirEmpty : Boolean get() = dirSize == 0
+
+fun File.isNewer(compareTo: File): Boolean {
+    return if (exists() && compareTo.exists())
+        FileUtils.isFileNewer(this, compareTo)
+    else false
+}
+
+fun File.copyDirectory(toDir: File?) {
+    if (exists()) {
+        toDir?.let {
+            FileUtils.copyDirectory(this, it)
+        }
+    }
+}
+
+fun File.readTextLines(maxLength: Long): String {
+    if (!exists()) error("File must exist: $this")
+    if (length() == 0L) error("File must not be empty: $this")
+    var complete = false
+    val text = StringBuilder()
+    forEachLine { line ->
+        if (complete) return@forEachLine
+        if (text.length + line.length < maxLength) {
+            text.append(line.trim()).append("\n")
+        } else {
+            complete = true
+        }
+    }
+    return text.toString()
+}
+
+fun assertPathIsDirectory(path: File) {
+    require(path.isDirectory) { String.format("'%s' is no directory", path) }
+}

+ 18 - 0
buildSrc/src/main/kotlin/org/mjdev/gradle/extensions/HashMapExt.kt

@@ -0,0 +1,18 @@
+/*
+ *  Copyright (c) Milan Jurkulák 2024.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */
+
+package org.mjdev.gradle.extensions
+
+fun <T, V> HashMap<T, V>.onKeyExists(
+    key: T,
+    block: ((key: T, value: V?) -> (Unit))
+) {
+    if (this.containsKey(key)) {
+        block.invoke(key, this[key])
+    }
+}

+ 7 - 0
buildSrc/src/main/kotlin/org/mjdev/gradle/extensions/KotlinDynamicCompiler.kt

@@ -0,0 +1,7 @@
+/*
+ *  Copyright (c) Milan Jurkulák 2024.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */

+ 87 - 0
buildSrc/src/main/kotlin/org/mjdev/gradle/extensions/KotlinPoetExt.kt

@@ -0,0 +1,87 @@
+/*
+ *  Copyright (c) Milan Jurkulák 2024.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */
+
+package org.mjdev.gradle.extensions
+
+import com.squareup.kotlinpoet.FileSpec
+import com.squareup.kotlinpoet.FunSpec
+import com.squareup.kotlinpoet.TypeSpec
+import java.io.File
+import kotlin.reflect.KClass
+
+@Suppress("unused")
+fun createFile(
+    name: String,
+    packageName: String = "",
+    block: FileScope.() -> Unit = {}
+): FileSpec = FileSpec.builder(packageName, name).apply {
+    val fileScope = FileScope()
+    block.invoke(fileScope)
+    fileScope.functions.forEach { funSpec ->
+        addFunction(funSpec)
+    }
+    fileScope.classes.forEach { classSpec ->
+        addType(classSpec)
+    }
+}.build()
+
+@Suppress("unused")
+class FileScope {
+    val functions = mutableListOf<FunSpec>()
+    val classes = mutableListOf<TypeSpec>()
+
+    fun createFnc(
+        fncName: String,
+        receiverType: KClass<*>? = null,
+        returnType: KClass<*>? = Unit::class,
+        block: FunSpec.Builder.() -> Unit = {}
+    ): FunSpec = FunSpec.builder(fncName).apply {
+        receiverType?.let { receiver(it) }
+        returnType?.let { returns(it) }
+        block.invoke(this)
+    }.build().also { fnSpec ->
+        functions.add(fnSpec)
+    }
+
+    fun createClass(
+        name: String,
+        block: ClassScope.() -> Unit = {}
+    ) {
+        TypeSpec.classBuilder(name).also { classSpec ->
+            val classScope = ClassScope(classSpec)
+            block.invoke(classScope)
+            classes.add(classSpec.build())
+        }
+    }
+}
+
+@Suppress("unused")
+class ClassScope(
+    private val builder: TypeSpec.Builder
+) {
+    fun createFnc(
+        name: String,
+        receiverType: KClass<*>? = null,
+        returnType: KClass<*>? = null,
+        block: FunSpec.Builder.() -> Unit = {}
+    ): FunSpec = FunSpec.builder(name).apply {
+        receiverType?.let { receiver(it) }
+        returnType?.let { returns(it) }
+        block.invoke(this)
+    }.build().also { fnSpec ->
+        builder.addFunction(fnSpec)
+    }
+}
+
+fun FileSpec.write(
+    outputDir: File,
+    block:FileSpec.()->Unit
+) {
+    writeTo(outputDir)
+    block.invoke(this)
+}

+ 8 - 2
buildSrc/src/main/kotlin/org/mjdev/gradle/extensions/PluginDependenciesSpecScope.kt

@@ -1,5 +1,5 @@
 /*
- *  Copyright (c) Milan Jurkulák 2023.
+ *  Copyright (c) Milan Jurkulák 2024.
  *  Contact:
  *  e: mimoccc@gmail.com
  *  e: mj@mjdev.org
@@ -43,4 +43,10 @@ fun PluginDependenciesSpecScope.androidApplication(): PluginDependencySpec =
     id("com.android.application")
 
 fun PluginDependenciesSpecScope.mainAppPlugin(): PluginDependencySpec =
-    id("MainAppPlugin")
+    id("AppPlugin")
+
+fun PluginDependenciesSpec.ktlint(includeVersion: Boolean = true): PluginDependencySpec =
+    id("org.jlleitschuh.gradle.ktlint").also { if (includeVersion) it.version("11.3.2") }
+
+fun PluginDependenciesSpec.detekt(includeVersion: Boolean = true): PluginDependencySpec =
+    id("io.gitlab.arturbosch.detekt").also { if (includeVersion) it.version("1.23.4") }

+ 14 - 0
buildSrc/src/main/kotlin/org/mjdev/gradle/extensions/PluginManager.kt

@@ -0,0 +1,14 @@
+/*
+ *  Copyright (c) Milan Jurkulák 2024.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */
+
+package org.mjdev.gradle.extensions
+
+import org.gradle.api.Plugin
+import org.gradle.api.plugins.PluginManager
+
+inline fun <reified T : Plugin<*>> PluginManager.apply() = apply(T::class.java)

+ 175 - 74
buildSrc/src/main/kotlin/org/mjdev/gradle/extensions/Project.kt

@@ -1,45 +1,40 @@
 /*
- *  Copyright (c) Milan Jurkulák 2023.
+ *  Copyright (c) Milan Jurkulák 2024.
  *  Contact:
  *  e: mimoccc@gmail.com
  *  e: mj@mjdev.org
  *  w: https://mjdev.org
  */
 
-@file:Suppress("unused")
+@file:Suppress("unused", "DEPRECATION")
 
 package org.mjdev.gradle.extensions
 
 import com.android.build.api.dsl.ApplicationExtension
 import com.android.build.api.variant.AndroidComponentsExtension
-import com.android.build.gradle.BaseExtension
-import com.android.build.gradle.api.AndroidBasePlugin
+import com.android.build.gradle.AppExtension
+import com.android.build.gradle.api.BaseVariant
+import org.gradle.TaskExecutionRequest
 import org.gradle.api.Action
-import org.gradle.api.GradleException
 import org.gradle.api.Project
 import org.gradle.api.Task
+import org.gradle.api.tasks.SourceSet
+import org.gradle.api.tasks.SourceSet.MAIN_SOURCE_SET_NAME
+import org.gradle.api.tasks.SourceSet.TEST_SOURCE_SET_NAME
+import org.gradle.api.tasks.SourceSetContainer
+import org.gradle.api.tasks.TaskProvider
 import org.gradle.kotlin.dsl.extra
+import org.gradle.kotlin.dsl.findByType
+import org.jetbrains.kotlin.gradle.dsl.KotlinProjectExtension
 import java.io.File
-import java.io.FileInputStream
-import java.util.Properties
-import java.util.regex.Pattern
-import kotlin.String
+import org.mjdev.gradle.base.BaseTask
+import kotlin.reflect.KClass
+import kotlin.reflect.full.isSubclassOf
 
 private const val VERSION_TAG_MAJOR = "majorVersion"
 private const val VERSION_TAG_MINOR = "minorVersion"
 private const val VERSION_TAG_PATCH = "patchVersion"
 
-private const val TASK_DOKKA = "dokkaGfm"
-
-fun Project.android(): BaseExtension {
-    val android = project.extensions.findByType(BaseExtension::class.java)
-    if (android != null) {
-        return android
-    } else {
-        throw GradleException("Project $name is not an Android project")
-    }
-}
-
 val Project.versionCode
     get(): Int {
         val major = runCatching {
@@ -68,75 +63,181 @@ val Project.versionName: String
         return "${major}.${minor}.${patch}"
     }
 
-private val Project.taskRequestsStr
-    get() = gradle.startParameter.taskRequests.toString()
+val Project.taskRequest: List<TaskExecutionRequest>
+    get() = gradle.startParameter.taskRequests
 
-val Project.currentFlavor: String
-    get() {
-        val pattern: Pattern = if (taskRequestsStr.contains("assemble")) {
-            Pattern.compile("assemble(\\w+)(Release|Debug)")
-        } else {
-            Pattern.compile("bundle(\\w+)(Release|Debug)")
+val Project.taskRequestsArgs: List<String>
+    get() = ArrayList<String>().also { argsList ->
+        taskRequest.map { tr ->
+            tr.args
+        }.let { list ->
+            list.forEach { argsList.addAll(it) }
         }
-        val matcher = pattern.matcher(taskRequestsStr)
-        val flavor = if (matcher.find()) {
-            matcher.group(1).lowercase()
-        } else {
-            ""
-        }
-        return flavor
     }
 
-val Project.dokkaGfm get() = tasks.findByName(TASK_DOKKA)
+val Project.taskRequestAssembleTasks
+    get() = taskRequestsArgs.filter { arg -> arg.startsWith("assemble") }
+
+val Project.taskRequestBundleTasks
+    get() = taskRequestsArgs.filter { arg -> arg.startsWith("bundle") }
+
+val Project.taskRequestHasAssembles
+    get() = taskRequestAssembleTasks.isNotEmpty()
+
+val Project.taskRequestHasBundles
+    get() = taskRequestBundleTasks.isNotEmpty()
+
+//val Project.currentFlavor: String
+//    get() {
+//        val buildTypes = arrayOf("Release", "Debug", "Mock")
+//        val str: String = when {
+//            taskRequestHasAssembles -> taskRequestAssembleTasks.joinToString { "," }
+//            taskRequestHasBundles -> taskRequestBundleTasks.joinToString { "," }
+//            else -> ""
+//        }
+//        return when {
+//            taskRequestHasAssembles -> Pattern
+//                .compile("assemble(\\w+)(${buildTypes.joinToString { "|" }})")
+//                .matcher(str)
+//                .let { matcher ->
+//                    if (matcher.find()) matcher.group(1).lowercase()
+//                    else ""
+//                }
+//
+//            taskRequestHasBundles -> Pattern
+//                .compile("bundle(\\w+)(${buildTypes.joinToString { "|" }})")
+//                .matcher(str)
+//                .let { matcher ->
+//                    if (matcher.find()) matcher.group(1).lowercase()
+//                    else ""
+//                }
+//
+//            else -> "-"
+//        }
+//    }
+
+val Project.android: ApplicationExtension
+    get() = extensions.getByType(ApplicationExtension::class.java)
 
-inline fun <reified T : Task> Project.registerTask(
-    name: String,
-    block: Action<T>
-) = project.tasks.register(name, T::class.java).configure(block)
+val Project.iaAndroid: Boolean
+    get() = extensions.findByType<ApplicationExtension>() != null
 
-val Project.android
-    get() = extensions.getByType(ApplicationExtension::class.java)
+val Project.packageName: String
+    get() = if (iaAndroid) android.defaultConfig.applicationId ?: "" else ""
 
-val Project.androidComponents
+val Project.androidComponents: AndroidComponentsExtension<*, *, *>
     get() = extensions.getByType(AndroidComponentsExtension::class.java)
 
-val Project.containsAndroidPlugin: Boolean
-    get() = project.plugins.toList().any { plugin -> plugin is AndroidBasePlugin }
-
-fun Project.loadKeyStoreProperties(
-    signConfigFilePath: String,
-    block: (keyAlias: String, keyPassword: String, storeFile: File, storePassword: String) -> Unit
-) {
-    val keystorePropertiesFile = project.rootProject.file(signConfigFilePath)
-    val keystoreProperties = Properties()
-    if (keystorePropertiesFile.exists()) {
-        keystoreProperties.load(FileInputStream(keystorePropertiesFile))
-    } else {
-        keystoreProperties["keyAlias"] = System.getenv("KEYSTORE_KEY_ALIAS").orEmpty()
-        keystoreProperties["keyPassword"] = System.getenv("KEYSTORE_KEY_PASSWORD").orEmpty()
-        keystoreProperties["storePassword"] =
-            System.getenv("KEYSTORE_STORE_PASSWORD").orEmpty()
-        keystoreProperties["storeFile"] = System.getenv("KEYSTORE_FILE").orEmpty()
+val Project.buildDirectory: File
+    get() = layout.buildDirectory.asFile.get()
+
+val Project.sourceSets
+    get() = properties["sourceSets"] as SourceSetContainer
+
+val Project.mainSourceSet: SourceSet?
+    get() = sourceSets.findByName(MAIN_SOURCE_SET_NAME)
+
+val Project.testSourceSet: SourceSet?
+    get() = sourceSets.findByName(TEST_SOURCE_SET_NAME)
+
+val Project.androidExtension: AppExtension
+    get() = extensions.getByType(AppExtension::class.java)
+
+val Project.kotlinExtension: KotlinProjectExtension
+    get() = extensions.getByName("kotlin") as KotlinProjectExtension
+
+val Project.assembleTasks: List<Task>
+    get() = mutableListOf<Task>().apply {
+        androidExtension.apply {
+            applicationVariants.all { variant ->
+                val task = project.tasks.getByName("assemble${variant.name.capitalize()}")
+                add(task)
+            }
+        }
+    }
+
+val Project.variants: List<BaseVariant>
+    get() = androidExtension.applicationVariants.toList()
+
+inline fun <reified T : Task> Project.registerTask(
+    name: String,
+    configurationAction: Action<T> = Action<T> {}
+): List<T> = if (T::class.isSubclassOf(BaseTask::class)) {
+    mutableListOf<T>().apply {
+        variants.forEach { variant ->
+            registerTask<T>(
+                "$name${variant.name.capitalize()}"
+            ).apply {
+                (this.get() as BaseTask).variant = variant
+            }.get().also { task ->
+                task.apply {
+                    configurationAction.execute(this)
+                    add(this)
+                }
+            }
+        }
     }
-    block.invoke(
-        keystoreProperties["keyAlias"] as String? ?: "",
-        keystoreProperties["keyPassword"] as? String? ?: "",
-        File(projectDir, keystoreProperties["storeFile"] as? String? ?: ""),
-        keystoreProperties["storePassword"] as? String? ?: ""
-    )
+} else {
+    listOf(registerTask<T>(name).get().apply {
+        configurationAction.execute(this)
+    })
 }
 
-fun Project.propOrDef(propertyName: String, defaultValue: Any): Any = project
-    .properties[propertyName] ?: (System.getenv(propertyName) ?: defaultValue)
+inline fun <reified T : Task> Project.registerTask(
+    name: String
+): TaskProvider<T> {
+//    println("> Registering task : $name[${T::class.simpleName}]")
+    return tasks.register(name, T::class.java)
+}
 
-fun Project.getFile(vararg fileNames: String): File? {
-    for (fileName in fileNames) {
-        val file = rootProject.file(fileName)
-        if (file.exists()) {
-            return file
+fun Project.registerSources(outDir: File, variant: BaseVariant) {
+    if (outDir.absolutePath.contains("generated/source")) {
+        project.kotlinExtension.sourceSets.findByName(variant.name)?.apply {
+            kotlin.srcDirs(outDir)
+        }
+        project.androidExtension.sourceSets.findByName(variant.name)?.apply {
+            kotlin.srcDirs(outDir)
         }
+    } else if (outDir.absolutePath.contains("generated/res")) {
+        project.androidExtension.sourceSets.findByName(variant.name)?.apply {
+            res.srcDirs(outDir)
+        }
+//        variant.mergeResourcesProvider.configure {
+//            it.dependsOn(task)
+//        }
     }
-    return null
 }
 
+@Suppress("UNCHECKED_CAST")
+fun <T> Project.extension(
+    name: String,
+    cls: Class<T>,
+    canCreate: Boolean = true
+): T? = extensions.findByName(name) as? T ?: if (canCreate) extensions.create(
+    name,
+    cls
+) else null
+
+fun <T : Any> Project.extension(
+    cls: KClass<T>,
+): T? = extensions.findByType(cls.java)
+
+fun Project.propOrDefault(propertyName: String, defaultValue: Any): Any = project
+    .properties[propertyName] ?: (System.getenv(propertyName) ?: defaultValue)
+
 fun Project.propertyOrEmpty(name: String): String = findProperty(name) as String? ?: ""
+
+fun Project.fileTree(directory: File, inc: String, exc: String): List<File> {
+    return project.fileTree(directory) {
+        setExcludes(listOf(exc))
+        setIncludes(listOf(inc))
+    }.toList()
+}
+
+fun Project.fileTree(directory: String, inc: String, exc: String): List<File> {
+    return project.fileTree(directory) {
+        setExcludes(listOf(exc))
+        setIncludes(listOf(inc))
+    }.toList()
+}
+

+ 47 - 48
buildSrc/src/main/kotlin/org/mjdev/gradle/extensions/SigningConfigs.kt

@@ -1,48 +1,47 @@
-/*
- *  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.gradle.extensions
-
-import com.android.build.gradle.BaseExtension
-import org.gradle.api.Project
-import java.io.FileInputStream
-import java.util.*
-import kotlin.String
-
-fun BaseExtension.setSigningConfigs(
-    project: Project
-) = signingConfigs {
-    create("signingConfigRelease") {
-        val rootProject = project.rootProject
-        val keystorePropertiesFile = rootProject.file("signing/release.signing.properties")
-        if (!keystorePropertiesFile.exists()) {
-            System.err.println("📜 Missing release.signing.properties file for release signing")
-        } else {
-            val keystoreProperties = Properties().apply {
-                load(FileInputStream(keystorePropertiesFile))
-            }
-            try {
-                storeFile = rootProject.file(keystoreProperties["storeFile"] as String)
-                storePassword = keystoreProperties["storePassword"] as String
-                keyAlias = keystoreProperties["keyAlias"] as String
-                keyPassword = keystoreProperties["keyPassword"] as String
-            } catch (e: Exception) {
-                System.err.println("📜 release.signing.properties file is malformed")
-            }
-        }
-    }
-
-    getByName("debug") {
-        storeFile = project.rootProject.file("signing/debug.keystore")
-        keyAlias = "androiddebugkey"
-        keyPassword = "android"
-        storePassword = "android"
-    }
-}
+///*
+// *  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.gradle.extensions
+//
+//import com.android.build.gradle.BaseExtension
+//import org.gradle.api.Project
+//import java.io.FileInputStream
+//import java.util.*
+//import kotlin.String
+//
+//fun BaseExtension.setSigningConfigs(
+//    project: Project
+//) = signingConfigs {
+//    create("signingConfigRelease") {
+//        val rootProject = project.rootProject
+//        val keystorePropertiesFile = rootProject.file("signing/release.signing.properties")
+//        if (!keystorePropertiesFile.exists()) {
+//            System.err.println("📜 Missing release.signing.properties file for release signing")
+//        } else {
+//            val keystoreProperties = Properties().apply {
+//                load(FileInputStream(keystorePropertiesFile))
+//            }
+//            try {
+//                storeFile = rootProject.file(keystoreProperties["storeFile"] as String)
+//                storePassword = keystoreProperties["storePassword"] as String
+//                keyAlias = keystoreProperties["keyAlias"] as String
+//                keyPassword = keystoreProperties["keyPassword"] as String
+//            } catch (e: Exception) {
+//                System.err.println("📜 release.signing.properties file is malformed")
+//            }
+//        }
+//    }
+//    getByName("debug") {
+//        storeFile = project.rootProject.file("signing/debug.keystore")
+//        keyAlias = "androiddebugkey"
+//        keyPassword = "android"
+//        storePassword = "android"
+//    }
+//}

+ 82 - 0
buildSrc/src/main/kotlin/org/mjdev/gradle/extensions/String.kt

@@ -6,8 +6,12 @@
  *  w: https://mjdev.org
  */
 
+@file:Suppress("unused")
+
 package org.mjdev.gradle.extensions
 
+import java.io.File
+import java.net.URLEncoder
 import java.util.Locale
 import kotlin.String
 
@@ -15,5 +19,83 @@ fun String.capitalize() = replaceFirstChar {
     if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString()
 }
 
+fun String.unCapitalize(): String {
+    if (isNotEmpty()) {
+        val firstChar = this[0]
+        if (firstChar.isUpperCase()) {
+            return buildString {
+                val titleChar = firstChar.lowercase()
+                if (titleChar != firstChar.lowercase()) {
+                    append(titleChar)
+                } else {
+                    append(this@unCapitalize.substring(0, 1).lowercase())
+                }
+                append(this@unCapitalize.substring(1))
+            }
+        }
+    }
+    return this
+}
+
 fun String?.suffixToString() =
     (this ?: "").replace(".", "").capitalize()
+
+fun String.writeToFile(path: String): File {
+    val file = File(path)
+    file.parentFile?.mkdirs()
+    this.writeToFile(file)
+    return file
+}
+
+fun String.writeToFile(file: File?): File? {
+    file?.parentFile?.mkdirs()
+    file?.writeText(this)
+    return file
+}
+
+fun String.writeToFile(dir: File?, fileName: String): File {
+    dir?.mkdirs()
+    val file = File(dir, fileName)
+    file.writeText(this)
+    return file
+}
+
+fun String?.urlEncode(encoding: String = "utf-8"): String? {
+    return this?.let { URLEncoder.encode(it, encoding) }
+}
+
+private val ESCAPES = object : HashMap<String, String>() {
+    init {
+        this["@"] = "\\@"
+        this["?"] = "\\?"
+        this["<"] = "&lt;"
+        this[">"] = "&gt;"
+        this["\'"] = "\\'"
+        this["\""] = "\\\""
+        this["&"] = "&amp;"
+        this["..."] = "…"
+    }
+}
+
+fun String.isCDATA(): Boolean {
+    return this.contains("<![CDATA[", true)
+}
+
+fun String?.escapeXml(): String {
+    return this?.let { s ->
+        val isCData = s.isCDATA()
+        val ret = StringBuilder().apply {
+            s.forEach { c -> ESCAPES[c.toString()]?.let { ec -> append(ec) } ?: append(c) }
+        }.toString()
+        if (isCData)
+            ret.replace("&lt;![CDATA[", "<![CDATA[").replace("]]&gt;", "]]>")
+        else
+            ret
+    } ?: ""
+}
+
+fun String?.toPlayStoreUrl(): String {
+    return (this ?: "").let { "https://play.google.com/store/apps/details?id=$it" }
+}
+
+fun List<String>.filterNotEmpty(): List<String> = filter { it.isNotEmpty() }

+ 91 - 0
buildSrc/src/main/kotlin/org/mjdev/gradle/extensions/SysUtils.kt

@@ -0,0 +1,91 @@
+package org.mjdev.gradle.extensions
+
+import org.gradle.api.Project
+import java.io.ByteArrayOutputStream
+import java.io.PrintStream
+
+@Suppress("unused")
+class SysUtils {
+    companion object {
+
+        fun runCommandWithOutput(project: Project, vararg cmd: String): SysCommandReturn {
+            val output = ByteArrayOutputStream()
+            try {
+                val cmdString = cmd.joinToString(" ")
+                project.exec {
+                    standardOutput = output
+                    errorOutput = output
+                    commandLine = listOf("sh", "-c", cmdString)
+                }.apply {
+                    return SysCommandReturn(exitValue, output)
+                }
+            } catch (t: Throwable) {
+                return SysCommandReturn(-1, t, output)
+            }
+        }
+    }
+
+    /**
+     * System command output
+     * */
+    class SysCommandReturn {
+        constructor(exitValue: Int, output: ByteArrayOutputStream) {
+            text = String(output.toByteArray())
+            exitCode = exitValue
+        }
+
+        constructor(exitValue: Int, t: Throwable?, output: ByteArrayOutputStream) {
+            text = String(output.toByteArray())
+            exitCode = exitValue
+            error = t
+        }
+
+        var text: String = ""
+            private set
+        var exitCode: Int = 0
+            private set
+        var error: Throwable? = null
+            private set
+
+        val errorText: String
+            get() {
+                val s = StringBuilder()
+                if (text.isNotEmpty()) {
+                    s.append(text)
+                    s.append("\n")
+                }
+                s.append(String.format("command exitCode: %s\n", exitCode))
+                if (error != null) {
+                    s.append(message)
+                    s.append("\n")
+                    s.append(stackTrace)
+                    s.append("\n")
+                }
+                return s.toString()
+            }
+
+        val message: String?
+            get() {
+                return error?.message
+            }
+
+        val stackTrace: String?
+            get() {
+                return error?.let {
+                    val o = ByteArrayOutputStream()
+                    it.printStackTrace(PrintStream(o))
+                    return String(o.toByteArray())
+                }
+            }
+
+        val isError: Boolean
+            get() {
+                return !isOK
+            }
+
+        val isOK: Boolean
+            get() {
+                return ((error == null) && (exitCode == 0))
+            }
+    }
+}

+ 265 - 0
buildSrc/src/main/kotlin/org/mjdev/gradle/extensions/TaskExt.kt

@@ -0,0 +1,265 @@
+/*
+ *  Copyright (c) Milan Jurkulák 2024.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */
+
+@file:Suppress("unused", "DEPRECATION")
+
+package org.mjdev.gradle.extensions
+
+import com.android.build.api.artifact.SingleArtifact
+import com.android.build.api.dsl.CommonExtension
+import com.android.build.api.variant.AndroidComponentsExtension
+import com.android.build.gradle.AppExtension
+import com.android.build.gradle.api.BaseVariant
+import org.gradle.StartParameter
+import org.gradle.api.DefaultTask
+import org.gradle.api.GradleException
+import org.gradle.api.Project
+import org.gradle.api.Task
+import org.gradle.api.plugins.AppliedPlugin
+import org.gradle.api.tasks.SourceSet.MAIN_SOURCE_SET_NAME
+import org.gradle.api.tasks.SourceSet.TEST_SOURCE_SET_NAME
+import org.gradle.api.tasks.TaskContainer
+import org.mjdev.gradle.base.BaseTask
+import org.mjdev.gradle.icons.LauncherIcon
+import org.mjdev.gradle.icons.utils.Resources
+import java.io.File
+
+val Task.projectName: String
+    get() = project.name
+
+val Task.projectVersion: String
+    get() = project.versionName
+
+val Task.tasks: TaskContainer
+    get() = project.tasks
+
+val Task.assembleTasks
+    get() = project.assembleTasks
+
+val Task.cleanTask: Task
+    get() = tasks.byName<Task>("clean")
+
+val Task.androidComponents: AndroidComponentsExtension<*, *, *>
+    get() = project.extensions.getByType(AndroidComponentsExtension::class.java)
+
+@Suppress("UnstableApiUsage")
+fun Task.finalizeDsl(block: CommonExtension<*, *, *, *, *>.() -> Unit) {
+    androidComponents.finalizeDsl { android ->
+        block(android)
+    }
+}
+
+//fun Task.registerSourceDirectory(
+//    dir: File
+//) = finalizeDsl {
+//    sourceSets.findByName("main")!!.kotlin.srcDirs(dir)
+//}
+
+//fun Task.registerSources(outDir: File, variant: BaseVariant) =
+//    project.registerSources(outDir, variant)
+
+//@Suppress("UnstableApiUsage")
+fun Task.registerSourceDirectory(outDir: File) {
+    android.sourceSets.findByName(MAIN_SOURCE_SET_NAME)?.kotlin?.srcDirs(outDir)
+    android.sourceSets.findByName(MAIN_SOURCE_SET_NAME)?.java?.srcDirs(outDir)
+//    androidComponents.onVariants { variant ->
+//        variant.sources.java?.addStaticSourceDirectory(outDir.absolutePath)
+//    }
+//    androidComponents.onVariants { variant ->
+//        variant.sources.kotlin?.addStaticSourceDirectory(outDir.absolutePath)
+//    }
+}
+
+fun Task.registerResourceDirectory(outDir: File) {
+    android.sourceSets.findByName(MAIN_SOURCE_SET_NAME)?.res?.srcDirs(outDir)
+//    androidComponents.onVariants { variant ->
+//        variant.sources.res?.addStaticSourceDirectory(outDir.absolutePath)
+//    }
+}
+
+//@Suppress("UnstableApiUsage")
+fun Task.registerTestSourceDirectory(outDir: File) {
+    android.sourceSets.findByName(TEST_SOURCE_SET_NAME)?.kotlin?.srcDirs(outDir)
+    android.sourceSets.findByName(TEST_SOURCE_SET_NAME)?.java?.srcDirs(outDir)
+//    androidComponents.onVariants { variant ->
+//        variant.sources.kotlin?.addStaticSourceDirectory(outDir.absolutePath)
+//    }
+//    androidComponents.onVariants { variant ->
+//        variant.sources.java?.addStaticSourceDirectory(outDir.absolutePath)
+//    }
+}
+
+//fun Task.manifest(block: () -> Unit) {
+//    androidComponents.onVariants { variant ->
+//        val manifestUpdater = project.tasks.register(
+//            variant.name + "ManifestUpdater",
+//            DefaultTask::class.java
+//        ) {
+//            block()
+//        }
+//        variant.artifacts.use(manifestUpdater).wiredWithFiles(
+//            DefaultTask::mergedManifest,
+//            DefaultTask::updatedManifest
+//        ).toTransform(SingleArtifact.MERGED_MANIFEST)
+//    }
+//}
+
+
+fun Task.runAfterCleanTask() {
+//    println("> Task order ${cleanTask.name} -> $name")
+    cleanTask.finalizedBy(this)
+}
+
+fun Task.runBeforeCleanTask() {
+    println("> Task order ${cleanTask.name} -> $name")
+    cleanTask.dependsOn(this)
+}
+
+fun Task.runAfterPreBuildTask() {
+    val variantName = (this as? BaseTask)?.variantName ?: ""
+    val task = tasks.byName<Task>("pre${variantName}Build")
+//    println("> Task order ${task.name} -> $name")
+    task.finalizedBy(this)
+}
+
+fun Task.runAfterAssembleTask() {
+    val variantName = (this as? BaseTask)?.variantName ?: ""
+    val task = tasks.byName<Task>("assemble${variantName}")
+//    println("> Task order ${task.name} -> $name")
+    task.finalizedBy(this)
+}
+
+fun Task.runBeforeAssembleTask() {
+    val variantName = (this as? BaseTask)?.variantName ?: ""
+    val task = tasks.byName<Task>("assemble${variantName}")
+//    println("> Task order ${task.name} -> $name")
+    task.dependsOn(this)
+}
+
+fun Task.findPlugin(
+    name: String
+): AppliedPlugin? = runCatching {
+    project.pluginManager.findPlugin(name)
+}.getOrNull()
+
+fun Task.applyPlugin(
+    name: String
+) = runCatching {
+    val pluginName = findPlugin(name)?.name
+    println("Applying plugin: ${pluginName ?: name}")
+    project.pluginManager.apply(name)
+}
+
+@Suppress("UNNECESSARY_SAFE_CALL")
+inline fun <reified T> Task.configureExtension(
+    crossinline block: T.() -> Unit
+) {
+    runCatching {
+        val name = T::class.simpleName?.replace("_Decorated", "")
+        println("Configuring extension: $name")
+        project.extensions.getByType(T::class.java)?.apply {
+            block(this)
+        } ?: throw (GradleException("Extension $name not found"))
+    }
+}
+
+inline fun <reified T : Task> TaskContainer.byType(): T =
+    withType(T::class.java).firstOrNull()
+        ?: throw GradleException("Task ${T::class.java} not found")
+
+inline fun <reified T : Task> TaskContainer.byName(name: String): T =
+    findByName(name) as T?
+        ?: throw GradleException("Task $name not found")
+
+inline fun <reified T : Task> Task.runAfter() =
+    tasks.byType<T>().finalizedBy(this)
+
+inline fun <reified T : Task> Task.shouldRunAfter() =
+    tasks.byType<T>().shouldRunAfter(this)
+
+fun Task.runAfterAllAssembleVariants(vararg exclude: String) = assembleTasks.map { task ->
+    var excluded = false
+    exclude.forEach { exclude ->
+        excluded = excluded or task.name.startsWith("assemble${exclude.capitalize()}")
+    }
+    if (!excluded) dependsOn(task) else null
+}
+
+fun Task.runAfterAllAssembleVariants(
+    filter: (Task) -> Boolean
+) = assembleTasks.filter(filter).onEach { task -> dependsOn(task) }
+
+fun Task.runBeforeAssemble() = assembleTasks.forEach { task -> task.dependsOn(this) }
+
+fun Task.setTaskDescription(description: String?): Task = this.apply {
+    description?.let { this.description = it }
+}
+
+val Task.android: AppExtension
+    get() = project.extensions.getByType(AppExtension::class.java)
+
+val Task.packageName: String?
+    get() = android.defaultConfig.applicationId
+
+val Task.variants: List<BaseVariant>
+    get() = android.applicationVariants.toTypedArray().toList()
+
+val Task.taskName: String
+    get() = this.name
+
+val Task.taskGroup: String
+    get() = this.group.toString()
+
+val Task.gradleParams: StartParameter
+    get() = project.gradle.startParameter
+
+val Task.taskParameters: Map<String, String>
+    get() = gradleParams.projectProperties
+
+val Task.runningTasks: List<String>
+    get() = gradleParams.taskNames
+
+val Task.hasAssembleTask: Boolean
+    get() = runningTasks.any { task -> task.lowercase().contains("assemble") }
+
+val Task.normalizedClassName: String
+    get() = this::class.simpleName!!.replace("_Decorated", "")
+
+val Task.resOutputDirectory
+    get() =
+        project.buildDirectory
+            .file("generated")
+            .file("res")
+            .file(normalizedClassName)
+            .file(variantName.lowercase())
+
+val Task.variantName: String
+    get() = (this as? BaseTask)?.variantName ?: ""
+
+val Task.srcOutputDirectory
+    get() = project.buildDirectory
+        .file("generated")
+        .file("source")
+        .file(normalizedClassName)
+        .file(variantName.lowercase())
+
+val BaseTask.androidManifestFiles: List<File>
+    get() = project.androidExtension.let { app ->
+        listOfNotNull(
+            "main",
+            variant.name,
+            variant.buildType?.name,
+            variant.flavorName
+        ).filterNotEmpty().distinct().map { variantName ->
+            project.file(app.sourceSets.getByName(variantName).manifest.srcFile)
+        }.filter { manifestFile ->
+            manifestFile.exists()
+        }
+    }
+
+

+ 30 - 0
buildSrc/src/main/kotlin/org/mjdev/gradle/icons/LauncherIcon.kt

@@ -0,0 +1,30 @@
+package org.mjdev.gradle.icons
+
+import org.mjdev.gradle.icons.filter.LauncherIconFilter
+import java.awt.image.BufferedImage
+import java.io.File
+import java.io.IOException
+import java.util.stream.Stream
+import javax.imageio.ImageIO
+
+class LauncherIcon(
+    inputFile: File,
+    val outputFile: File
+) {
+
+    private val image: BufferedImage = ImageIO.read(inputFile)
+
+    @Throws(IOException::class)
+    fun save() {
+        outputFile.parentFile.mkdirs()
+        ImageIO.write(image, "png", outputFile)
+        println("saving icon : ${File("$outputFile.png").absolutePath}")
+    }
+
+    fun process(filters: Stream<LauncherIconFilter>) {
+        filters.forEach { filter: LauncherIconFilter ->
+            filter.apply(image)
+        }
+    }
+
+}

+ 150 - 0
buildSrc/src/main/kotlin/org/mjdev/gradle/icons/LauncherIconConfig.kt

@@ -0,0 +1,150 @@
+//package org.mjdev.gradle.icons
+//
+//import org.gradle.api.tasks.Input
+//import org.mjdev.gradle.icons.filter.ColorRibbonFilter
+//import org.mjdev.gradle.icons.filter.GrayscaleFilter
+//import org.mjdev.gradle.icons.filter.LauncherIconFilter
+//import org.mjdev.gradle.icons.filter.OverlayFilter
+//import java.awt.Color
+//import java.io.File
+//import java.io.Serializable
+//
+//@Suppress("unused", "DefaultLocale", "MemberVisibilityCanBePrivate")
+//class LauncherIconConfig(var name: String) : Serializable {
+//    private val mFilters: ArrayList<LauncherIconFilter> = ArrayList()
+//
+//    var enabled = true
+//
+//    val filters: ArrayList<LauncherIconFilter>
+//        get() = mFilters
+//
+//    private fun filters(vararg filters: LauncherIconFilter) {
+//        mFilters.addAll(listOf(*filters))
+//    }
+//
+//    @Input
+//    fun enable(enabled: Boolean): LauncherIconConfig {
+//        this.enabled = enabled
+//        return this
+//    }
+//
+//    @Input
+//    fun setFilters(filters: Iterable<LauncherIconFilter?>?): LauncherIconConfig {
+//        filters?.let { fts -> fts.forEach { f -> f?.let { mFilters.add(it) } } }
+//        return this
+//    }
+//
+//    @Input
+//    fun setFilters(filter: LauncherIconFilter): LauncherIconConfig {
+//        mFilters.add(filter)
+//        return this
+//    }
+//
+//    fun customColorRibbonFilter(
+//            name: String?,
+//            ribbonColor: String?,
+//            labelColor: String?,
+//            position: String,
+//            textSizeRatio: Float
+//    ): ColorRibbonFilter {
+//        return ColorRibbonFilter(name, Color.decode(ribbonColor), Color.decode(labelColor), ColorRibbonFilter.Gravity.valueOf(position.uppercase()), textSizeRatio)
+//    }
+//
+//    fun customColorRibbonFilter(
+//            name: String?,
+//            ribbonColor: String?,
+//            labelColor: String?,
+//            gravity: String
+//    ): ColorRibbonFilter {
+//        return ColorRibbonFilter(name, Color.decode(ribbonColor), Color.decode(labelColor), ColorRibbonFilter.Gravity.valueOf(gravity.uppercase()))
+//    }
+//
+//    fun customColorRibbonFilter(
+//            name: String?,
+//            ribbonColor: String?,
+//            labelColor: String?
+//    ): ColorRibbonFilter {
+//        return ColorRibbonFilter(name, Color.decode(ribbonColor), Color.decode(labelColor))
+//    }
+//
+//    fun customColorRibbonFilter(
+//            name: String?,
+//            ribbonColor: String?
+//    ): ColorRibbonFilter {
+//        return ColorRibbonFilter(name, Color.decode(ribbonColor))
+//    }
+//
+//    fun customColorRibbonFilter(
+//            ribbonColor: String?
+//    ): ColorRibbonFilter {
+//        return ColorRibbonFilter(name, Color.decode(ribbonColor))
+//    }
+//
+//    fun grayRibbonFilter(
+//            name: String?
+//    ): ColorRibbonFilter {
+//        return ColorRibbonFilter(name, Color(0x60, 0x60, 0x60, 0x99))
+//    }
+//
+//    fun grayRibbonFilter(): ColorRibbonFilter {
+//        return ColorRibbonFilter(name, Color(0x60, 0x60, 0x60, 0x99))
+//    }
+//
+//    fun greenRibbonFilter(name: String?): ColorRibbonFilter {
+//        return ColorRibbonFilter(name, Color(0, 0x72, 0, 0x99))
+//    }
+//
+//    fun greenRibbonFilter(): ColorRibbonFilter {
+//        return ColorRibbonFilter(name, Color(0, 0x72, 0, 0x99))
+//    }
+//
+//    fun orangeRibbonFilter(
+//            name: String?
+//    ): ColorRibbonFilter {
+//        return ColorRibbonFilter(name, Color(0xff, 0x76, 0, 0x99))
+//    }
+//
+//    fun orangeRibbonFilter(): ColorRibbonFilter {
+//        return ColorRibbonFilter(name, Color(0xff, 0x76, 0, 0x99))
+//    }
+//
+//    fun yellowRibbonFilter(
+//            name: String?
+//    ): ColorRibbonFilter {
+//        return ColorRibbonFilter(name, Color(0xff, 251, 0, 0x99))
+//    }
+//
+//    fun yellowRibbonFilter(): ColorRibbonFilter {
+//        return ColorRibbonFilter(name, Color(0xff, 251, 0, 0x99))
+//    }
+//
+//    fun redRibbonFilter(
+//            name: String?
+//    ): ColorRibbonFilter {
+//        return ColorRibbonFilter(name, Color(0xff, 0, 0, 0x99))
+//    }
+//
+//    fun redRibbonFilter(): ColorRibbonFilter {
+//        return ColorRibbonFilter(name, Color(0xff, 0, 0, 0x99))
+//    }
+//
+//    fun blueRibbonFilter(
+//            name: String?
+//    ): ColorRibbonFilter {
+//        return ColorRibbonFilter(name, Color(0, 0, 255, 0x99))
+//    }
+//
+//    fun blueRibbonFilter(): ColorRibbonFilter {
+//        return ColorRibbonFilter(name, Color(0, 0, 255, 0x99))
+//    }
+//
+//    fun overlayFilter(
+//            fgFile: File?
+//    ): OverlayFilter {
+//        return OverlayFilter(fgFile!!)
+//    }
+//
+//    fun grayscaleFilter(): GrayscaleFilter {
+//        return GrayscaleFilter()
+//    }
+//}

+ 64 - 0
buildSrc/src/main/kotlin/org/mjdev/gradle/icons/LauncherIconExtension.kt

@@ -0,0 +1,64 @@
+//package org.mjdev.gradle.icons
+//
+//import org.gradle.api.tasks.Input
+//
+//@Suppress("unused", "MemberVisibilityCanBePrivate")
+//open class LauncherIconExtension {
+////    private var iconNames: MutableSet<String> = HashSet()
+////    private var foregroundIconNames: MutableSet<String> = HashSet()
+//
+////    var isDefaultFlavorNaming = false
+//
+////    fun getIconNames(): Set<String> {
+////        return iconNames
+////    }
+//
+////    @Input
+////    fun setIconNames(resNames: Collection<String>) {
+////        iconNames = HashSet(resNames)
+////    }
+//
+////    @Input
+////    fun iconNames(resNames: Collection<String>) {
+////        setIconNames(resNames)
+////    }
+//
+////    @Input
+////    fun iconNames(vararg resNames: String) {
+////        setIconNames(listOf(*resNames))
+////    }
+//
+////    @Input
+////    fun iconName(resName: String) {
+////        iconNames.add(resName)
+////    }
+//
+////    fun getForegroundIconNames(): Set<String> {
+////        return foregroundIconNames
+////    }
+//
+////    @Input
+////    fun setForegroundIconNames(resNames: Collection<String>) {
+////        foregroundIconNames = HashSet(resNames)
+////    }
+//
+////    @Input
+////    fun foregroundIconNames(resNames: Collection<String>) {
+////        setForegroundIconNames(resNames)
+////    }
+//
+////    @Input
+////    fun foregroundIconNames(vararg resNames: String) {
+////        setForegroundIconNames(listOf(*resNames))
+////    }
+//
+////    @Input
+////    fun foregroundIconName(resName: String) {
+////        foregroundIconNames.add(resName)
+////    }
+//
+////    @Input
+////    fun defaultFlavorNaming(defaultFlavorNaming: Boolean) {
+////        isDefaultFlavorNaming = defaultFlavorNaming
+////    }
+//}

+ 112 - 0
buildSrc/src/main/kotlin/org/mjdev/gradle/icons/LauncherIconPlugin.kt

@@ -0,0 +1,112 @@
+//package org.mjdev.gradle.icons
+//
+//import com.android.build.gradle.api.ApplicationVariant
+//import org.gradle.api.NamedDomainObjectContainer
+//import org.gradle.api.Plugin
+//import org.gradle.api.Project
+//import org.gradle.api.Task
+//import org.mjdev.gradle.extensions.androidExtension
+//import org.mjdev.gradle.extensions.buildDirectory
+//import org.mjdev.gradle.extensions.extension
+//import org.mjdev.gradle.icons.filter.LauncherIconFilter
+//import java.io.File
+//
+//@Suppress("DefaultLocale")
+//class LauncherIconPlugin : Plugin<Project> {
+//    companion object {
+//        const val PLUGIN_NAME = "launcherIcon"
+//        const val RIBBON_VARIANTS = "variants"
+//        const val RIBBON_BUILD_TYPES = "buildTypes"
+//        const val RIBBON_PRODUCT_FLAVORS = "productFlavors"
+//    }
+//
+//    fun getGeneratedResDir(project: Project, variant: ApplicationVariant): File {
+//        return File(project.buildDirectory, "generated/$PLUGIN_NAME/res/${variant.name}")
+//    }
+//
+//    @Override
+//    override fun apply(project: Project) {
+//        project.createExtension<LauncherIconExtension>()
+//        val ribbonVariants: NamedDomainObjectContainer<LauncherIconConfig> =
+//                project.container(LauncherIconConfig::class.java)
+//        ribbonVariants.let {
+//            project.extensions.add(RIBBON_VARIANTS, it)
+//        }
+//        val ribbonBuildTypes: NamedDomainObjectContainer<LauncherIconConfig> =
+//                project.container(LauncherIconConfig::class.java)
+//        ribbonBuildTypes.let {
+//            project.extensions.add(RIBBON_BUILD_TYPES, it)
+//        }
+//        val ribbonProductFlavors: NamedDomainObjectContainer<LauncherIconConfig> =
+//                project.container(LauncherIconConfig::class.java)
+//        ribbonProductFlavors.let {
+//            project.extensions.add(RIBBON_PRODUCT_FLAVORS, it)
+//        }
+//        project.afterEvaluate {
+//            project.androidExtension.let { app ->
+//                val extension = project.extension(LauncherIconExtension::class)
+//                val tasks = ArrayList<Task>()
+//                app.applicationVariants.all { variant ->
+//                    val configs = mutableListOf<LauncherIconConfig>()
+//                    ribbonVariants.forEach { econfig ->
+//                        if (variant.name == econfig.name)
+//                            configs.add(econfig)
+//                    }
+//                    if (configs.isEmpty()) {
+//                        ribbonProductFlavors.forEach { econfig ->
+//                            if (variant.flavorName == econfig.name)
+//                                configs.add(econfig)
+//                        }
+//                        ribbonBuildTypes.forEach { econfig ->
+//                            if (variant.buildType.name == econfig.name)
+//                                configs.add(econfig)
+//                        }
+//                    }
+//                    var enabled = true
+//                    configs.forEach { econfig ->
+//                        enabled = enabled && econfig.enabled
+//                    }
+//                    if (enabled) {
+//                        val filters = ArrayList<LauncherIconFilter>()
+//                        configs.forEach { econfig ->
+//                            filters.addAll(econfig.filters)
+//                        }
+//                        if (filters.isEmpty() && variant.buildType.isDebuggable) {
+//                            val ribbonText = if (extension?.isDefaultFlavorNaming == true)
+//                                variant.flavorName
+//                            else
+//                                variant.buildType.name
+//                            filters.add(LauncherIconConfig(ribbonText).greenRibbonFilter())
+//                        }
+//                        val generatedResDir = getGeneratedResDir(project, variant)
+//                        app.sourceSets.findByName(variant.name)?.res?.srcDir(generatedResDir)
+//                        project.createTask<EasyLauncherTask>(
+//                                "$PLUGIN_NAME${variant.name.capitalize()}"
+//                        ) { task ->
+//                            task.let {
+//                                it.setTaskDescription("Generate icon due application variant")
+//                                it.variant = variant
+//                                it.outputDir = generatedResDir
+//                                it.iconNames = extension?.getIconNames() ?: emptySet()
+//                                it.foregroundIconNames = extension?.getForegroundIconNames()
+//                                        ?: emptySet()
+//                                it.filters = filters
+//                                tasks.add(it)
+//                                project.getTasksByName(
+//                                        "generate${variant.name.capitalize()}Resources",
+//                                        false
+//                                ).forEach { t ->
+//                                    t.dependsOn(task)
+//                                }
+//                            }
+//                        }
+//                    }
+//                }
+//                project.task(PLUGIN_NAME).dependsOn(tasks)
+//            } ?: throw Exception(
+//                    "Not an Android application; did you forget " +
+//                            "use `apply plugin: 'com.android.application`?"
+//            )
+//        }
+//    }
+//}

+ 95 - 0
buildSrc/src/main/kotlin/org/mjdev/gradle/icons/LauncherIconTask.kt

@@ -0,0 +1,95 @@
+/*
+ *  Copyright (c) Milan Jurkulák 2024.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */
+
+package org.mjdev.gradle.icons
+
+import org.gradle.api.tasks.OutputDirectory
+import org.gradle.api.tasks.TaskAction
+import org.mjdev.gradle.base.BaseTask
+import org.mjdev.gradle.extensions.androidManifestFiles
+import org.mjdev.gradle.extensions.fileTree
+import org.mjdev.gradle.extensions.resOutputDirectory
+import org.mjdev.gradle.icons.filter.LauncherIconFilter
+import org.mjdev.gradle.icons.utils.Resources
+import java.io.File
+
+@Suppress("MemberVisibilityCanBePrivate")
+open class LauncherIconTask : BaseTask() {
+
+    val outputDir
+        @OutputDirectory
+        get()= resOutputDirectory
+
+    private var iconNames: MutableSet<String> = HashSet()
+    private var foregroundIconNames: MutableSet<String> = HashSet()
+
+    private var filters: List<LauncherIconFilter> = ArrayList()
+
+    private val launcherIconNames: Set<String>
+        get() = HashSet<String>().apply {
+            androidManifestFiles.forEach { manifestFile ->
+                addAll(Resources.getLauncherIcons(manifestFile))
+            }
+        }
+
+    init {
+        group = "mjdev"
+        description = "Generate icon due application variant"
+    }
+
+    @TaskAction
+    fun run() {
+        if (filters.isEmpty()) return
+        val names = HashSet<String>().apply {
+            iconNames.forEach { add(it) }
+            addAll(launcherIconNames)
+        }
+        val foregroundNames = HashSet<String>().apply {
+            foregroundIconNames.forEach { add(it) }
+        }
+        variant.sourceSets.forEach { sourceProvider ->
+            sourceProvider.resDirectories.forEach { resDir ->
+                if (resDir.absolutePath == outputDir.absolutePath) return
+                names.forEach { name ->
+                    project.fileTree(
+                        resDir,
+                        Resources.resourceFilePattern(name),
+                        "**/*.xml"
+                    ).forEach { inputFile ->
+                        val basename: String? = inputFile.name
+                        val resType: String? = inputFile.parentFile.name
+                        val outputFile = File(outputDir, "${resType}/${basename}")
+                        outputFile.parentFile.mkdirs()
+                        val easyLauncher = LauncherIcon(inputFile, outputFile)
+                        easyLauncher.process(filters.stream())
+                        easyLauncher.save()
+                    }
+                }
+                foregroundNames.forEach { name ->
+                    project.fileTree(
+                        resDir,
+                        Resources.resourceFilePattern(name),
+                        "**/*.xml"
+                    ).forEach { inputFile ->
+                        val basename = inputFile.name
+                        val resType = inputFile.parentFile.name
+                        val outputFile = File(outputDir, "${resType}/${basename}")
+                        outputFile.parentFile.mkdirs()
+                        val largeRibbonFilters = filters.map { filter ->
+                            filter.setAdaptiveLauncherMode(true)
+                            filter
+                        }
+                        val easyLauncher = LauncherIcon(inputFile, outputFile)
+                        easyLauncher.process(largeRibbonFilters.stream())
+                        easyLauncher.save()
+                    }
+                }
+            }
+        }
+    }
+}

+ 145 - 0
buildSrc/src/main/kotlin/org/mjdev/gradle/icons/filter/ColorRibbonFilter.kt

@@ -0,0 +1,145 @@
+/*
+ *  Copyright (c) Milan Jurkulák 2024.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */
+
+package org.mjdev.gradle.icons.filter
+
+import java.awt.Color
+import java.awt.Font
+import java.awt.Graphics2D
+import java.awt.RenderingHints
+import java.awt.font.FontRenderContext
+import java.awt.geom.AffineTransform
+import java.awt.image.BufferedImage
+import kotlin.math.pow
+import kotlin.math.sqrt
+
+class ColorRibbonFilter(
+    private val label: String?,
+    private val ribbonColor: Color,
+    private val labelColor: Color = Color.WHITE,
+    private val gravity: Gravity = Gravity.TOP_LEFT,
+    private val textSizeRatio: Float = -1f,
+    private val fontName: String = "DEFAULT",
+    private val fontStyle: Int = Font.PLAIN,
+    private var largeRibbon: Boolean = false
+) : LauncherIconFilter {
+
+    enum class Gravity {
+        TOP, BOTTOM, TOP_LEFT, TOP_RIGHT
+    }
+
+    override fun setAdaptiveLauncherMode(enable: Boolean) {
+        largeRibbon = enable
+    }
+
+    override fun apply(image: BufferedImage) {
+        val imageWidth = image.width
+        val imageHeight = image.height
+        val g = image.graphics as Graphics2D
+        when (gravity) {
+            Gravity.TOP,
+            Gravity.BOTTOM -> {
+            }
+
+            Gravity.TOP_RIGHT -> g.transform = AffineTransform.getRotateInstance(
+                Math.toRadians(45.0),
+                imageWidth.toDouble(),
+                0.0
+            )
+
+            Gravity.TOP_LEFT -> g.transform = AffineTransform.getRotateInstance(
+                Math.toRadians(-45.0)
+            )
+        }
+        val frc = FontRenderContext(g.transform, true, true)
+        val maxLabelWidth = calculateMaxLabelWidth(imageHeight / 2)
+        g.font = getFont(imageHeight, maxLabelWidth, frc)
+        val textBounds = g.font.getStringBounds(label ?: "", frc)
+        val textHeight = textBounds.height.toInt()
+        val textPadding = textHeight / 10
+        val labelHeight = textHeight + textPadding * 2
+        val y: Int = when (gravity) {
+            Gravity.TOP -> if (largeRibbon) imageHeight / 4 else 0
+            Gravity.BOTTOM -> imageHeight - labelHeight - (if (largeRibbon) imageHeight / 4 else 0)
+            Gravity.TOP_RIGHT, Gravity.TOP_LEFT -> imageHeight / (if (largeRibbon) 2 else 4)
+        }
+        g.color = ribbonColor
+        when (gravity) {
+            Gravity.TOP, Gravity.BOTTOM -> {
+                g.fillRect(0, y, imageWidth, labelHeight)
+            }
+            Gravity.TOP_RIGHT -> {
+                g.fillRect(0, y, imageWidth * 2, labelHeight)
+            }
+            else -> {
+                g.fillRect(-imageWidth, y, imageWidth * 2, labelHeight)
+            }
+        }
+        if (label != null) {
+            g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON)
+            g.color = labelColor
+            val fm = g.fontMetrics
+            when (gravity) {
+                Gravity.TOP, Gravity.BOTTOM -> {
+                    g.drawString(
+                        label,
+                        imageWidth / 2 - textBounds.width.toInt() / 2,
+                        y + fm.ascent
+                    )
+                }
+                Gravity.TOP_RIGHT -> {
+                    g.drawString(
+                        label,
+                        imageWidth - textBounds.width.toInt() / 2,
+                        y + fm.ascent
+                    )
+                }
+                else -> {
+                    g.drawString(
+                        label,
+                        (-textBounds.width).toInt() / 2,
+                        y + fm.ascent
+                    )
+                }
+            }
+        }
+        g.dispose()
+    }
+
+    private fun getFont(imageHeight: Int, maxLabelWidth: Int, frc: FontRenderContext): Font {
+        if (textSizeRatio != -1f) {
+            return Font(fontName, fontStyle, (imageHeight * textSizeRatio).toInt())
+        }
+        var max = imageHeight / 8
+        var min = 0
+        if (label == null) {
+            return Font(fontName, fontStyle, max / 2)
+        }
+        var size = max
+        for (i in 0..9) {
+            val mid = (max + min) / 2
+            if (mid == size) {
+                break
+            }
+            val font = Font(fontName, fontStyle, mid)
+            val labelBounds = font.getStringBounds(label, frc)
+            if (labelBounds.width.toInt() > maxLabelWidth) {
+                max = mid
+            } else {
+                min = mid
+            }
+            size = mid
+        }
+        return Font(fontName, fontStyle, size)
+    }
+
+    private fun calculateMaxLabelWidth(y: Int): Int {
+        return sqrt(y.toDouble().pow(2.0) * 2).toInt()
+    }
+
+}

+ 37 - 0
buildSrc/src/main/kotlin/org/mjdev/gradle/icons/filter/GrayscaleFilter.kt

@@ -0,0 +1,37 @@
+/*
+ *  Copyright (c) Milan Jurkulák 2024.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */
+
+package org.mjdev.gradle.icons.filter
+
+import java.awt.image.BufferedImage
+
+class GrayscaleFilter : LauncherIconFilter {
+    override fun setAdaptiveLauncherMode(enable: Boolean) {}
+
+    override fun apply(image: BufferedImage) {
+        val width = image.width
+        val height = image.height
+        for (y in 0 until height) {
+            for (x in 0 until width) {
+                val color = image.getRGB(x, y)
+                image.setRGB(x, y, toGray(color))
+            }
+        }
+    }
+
+    companion object {
+        private fun toGray(color: Int): Int {
+            val a = color and -0x1000000
+            val r = color and 0x00FF0000 shr 16
+            val g = color and 0x0000FF00 shr 8
+            val b = color and 0x000000FF
+            val c = ((2.0 * r + 4.0 * g + b) / 7.0).toInt()
+            return a or (c shl 16) or (c shl 8) or c
+        }
+    }
+}

+ 8 - 0
buildSrc/src/main/kotlin/org/mjdev/gradle/icons/filter/LauncherIconFilter.kt

@@ -0,0 +1,8 @@
+package org.mjdev.gradle.icons.filter
+
+import java.awt.image.BufferedImage
+
+interface LauncherIconFilter {
+    fun setAdaptiveLauncherMode(enable: Boolean)
+    fun apply(image: BufferedImage)
+}

+ 48 - 0
buildSrc/src/main/kotlin/org/mjdev/gradle/icons/filter/OverlayFilter.kt

@@ -0,0 +1,48 @@
+/*
+ *  Copyright (c) Milan Jurkulák 2024.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */
+
+package org.mjdev.gradle.icons.filter
+
+import java.awt.Image
+import java.awt.image.BufferedImage
+import java.io.File
+import java.io.IOException
+import javax.imageio.ImageIO
+import kotlin.math.min
+
+class OverlayFilter(
+        private val fgFile: File
+) : LauncherIconFilter {
+    private var addPadding = false
+
+    override fun setAdaptiveLauncherMode(enable: Boolean) {
+        addPadding = enable
+    }
+
+    override fun apply(image: BufferedImage) {
+        var fgImage: Image? = null
+        try {
+            fgImage = ImageIO.read(fgFile)
+        } catch (e: IOException) {
+            e.printStackTrace()
+        }
+        if (fgImage != null) {
+            val width = image.width
+            val height = image.width
+            var scale = min(width / fgImage.getWidth(null).toFloat(), height / fgImage.getHeight(null).toFloat())
+            if (addPadding) scale *= (72f / 108)
+            val fgImageScaled = fgImage.getScaledInstance(
+                    (fgImage.getWidth(null) * scale).toInt(),
+                    (fgImage.getWidth(null) * scale).toInt(),
+                    Image.SCALE_SMOOTH)
+            val g = image.createGraphics()
+            if (addPadding) g.drawImage(fgImageScaled, (width * (1 - 72f / 108) / 2).toInt(), (height * (1 - 72f / 108) / 2).toInt(), null) else g.drawImage(fgImageScaled, 0, 0, null)
+            g.dispose()
+        }
+    }
+}

+ 49 - 0
buildSrc/src/main/kotlin/org/mjdev/gradle/icons/utils/Resources.kt

@@ -0,0 +1,49 @@
+/*
+ *  Copyright (c) Milan Jurkulák 2024.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */
+
+@file:Suppress("DEPRECATION")
+
+package org.mjdev.gradle.icons.utils
+
+import groovy.util.XmlSlurper
+import groovy.util.slurpersupport.GPathResult
+import org.xml.sax.SAXException
+import java.io.File
+import java.io.IOException
+import java.util.Collections
+import javax.xml.parsers.ParserConfigurationException
+import kotlin.collections.ArrayList
+
+object Resources {
+
+    fun resourceFilePattern(name: String): String {
+        return if (name.startsWith("@")) {
+            val pair = name.substring(1).split("/".toRegex(), 2).toTypedArray()
+            val baseResType = pair[0]
+            val fileName = pair[1]
+            "$baseResType*/$fileName.*"
+        } else name
+    }
+
+    @Throws(SAXException::class, ParserConfigurationException::class, IOException::class)
+    fun getLauncherIcons(manifestFile: File?): List<String> {
+        val manifestXml = XmlSlurper().parse(manifestFile)
+        val applicationNode = manifestXml.getProperty("application") as GPathResult
+        val icon = applicationNode.getProperty("@android:icon").toString()
+        val roundIcon = applicationNode.getProperty("@android:roundIcon").toString()
+        val icons: MutableList<String> = ArrayList(2)
+        if (icon.isNotEmpty()) {
+            icons.add(icon)
+        }
+        if (roundIcon.isNotEmpty()) {
+            icons.add(roundIcon)
+        }
+        return Collections.unmodifiableList(icons)
+    }
+
+}

+ 0 - 14
buildSrc/src/main/kotlin/org/mjdev/gradle/libs/Libs.kt

@@ -1,14 +0,0 @@
-/*
- *  Copyright (c) Milan Jurkulák 2023.
- *  Contact:
- *  e: mimoccc@gmail.com
- *  e: mj@mjdev.org
- *  w: https://mjdev.org
- */
-
-package org.mjdev.gradle.libs
-
-@Suppress("unused")
-object Libs {
-
-}

+ 293 - 0
buildSrc/src/main/kotlin/org/mjdev/gradle/plugin/AppPlugin.kt

@@ -0,0 +1,293 @@
+/*
+ *  Copyright (c) Milan Jurkulák 2024.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */
+
+package org.mjdev.gradle.plugin
+
+import com.android.build.gradle.internal.api.BaseVariantOutputImpl
+import com.android.build.gradle.internal.dsl.BaseAppModuleExtension
+import org.gradle.api.JavaVersion
+import org.gradle.kotlin.dsl.dependencies
+import org.jetbrains.kotlin.gradle.InternalKotlinGradlePluginApi
+import org.mjdev.gradle.base.PluginBase
+import org.mjdev.gradle.dependency.anotherDependencies
+import org.mjdev.gradle.dependency.baseDependencies
+import org.mjdev.gradle.dependency.composeDependencies
+import org.mjdev.gradle.dependency.daggerDependencies
+import org.mjdev.gradle.dependency.encryptDependencies
+import org.mjdev.gradle.dependency.exoPlayerDependencies
+import org.mjdev.gradle.dependency.glideDependencies
+import org.mjdev.gradle.dependency.mjdevTvLib
+import org.mjdev.gradle.dependency.moshiDependencies
+import org.mjdev.gradle.dependency.okHttpDependencies
+import org.mjdev.gradle.dependency.permissionsDependencies
+import org.mjdev.gradle.dependency.retrofitDependencies
+import org.mjdev.gradle.dependency.sandwichDependencies
+import org.mjdev.gradle.dependency.testDependencies
+import org.mjdev.gradle.dependency.timberDependencies
+import org.mjdev.gradle.dependency.widgetDependencies
+import org.mjdev.gradle.extensions.capitalize
+import org.mjdev.gradle.extensions.file
+import org.mjdev.gradle.extensions.versionCode
+import org.mjdev.gradle.extensions.versionName
+import org.mjdev.gradle.extensions.runAfterCleanTask
+import org.mjdev.gradle.extensions.runAfterAssembleTask
+import org.mjdev.gradle.tasks.ReleaseNotesCreateTask
+import org.mjdev.gradle.tasks.ReleaseNotesCleanTask
+import org.mjdev.gradle.tasks.DependencyUpdateTask
+import org.mjdev.gradle.tasks.ZipReleaseClearTask
+import org.mjdev.gradle.tasks.DependencyUpdateCleanTask
+import java.util.Date
+import  org.mjdev.gradle.extensions.runAfter
+import org.mjdev.gradle.extensions.runAfterPreBuildTask
+import org.mjdev.gradle.icons.LauncherIconTask
+import org.mjdev.gradle.tasks.DetektCleanTask
+import org.mjdev.gradle.tasks.DetektCreateTask
+import org.mjdev.gradle.tasks.DokkaCleanTask
+import org.mjdev.gradle.tasks.DokkaCreateTask
+import org.mjdev.gradle.tasks.InAppInfoCreateTask
+import org.mjdev.gradle.tasks.ZipReleaseCreateTask
+import org.mjdev.gradle.unittest.TestCaseGeneratorTask
+import java.util.Locale
+
+@Suppress("unused", "MemberVisibilityCanBePrivate")
+class AppPlugin : PluginBase<AppPluginParams, BaseAppModuleExtension>(AppPluginParams::class.java, {
+    plugins {
+//        apply("org.jetbrains.kotlin.android")
+//        apply("com.android.application")
+        apply("kotlin-parcelize")
+        apply("com.google.dagger.hilt.android")
+        apply("dagger.hilt.android.plugin")
+        apply("com.google.devtools.ksp")
+        apply("io.objectbox")
+        apply("org.jetbrains.dokka")
+        //id("com.google.android.libraries.mapsplatform.secrets-gradle-plugin")
+        //id ("app.cash.paparazzi") version "1.3.1"
+    }
+//    repositories {
+//        mavenLocal()
+//        mavenCentral()
+//        @Suppress("DEPRECATION")
+//        jcenter()
+//    }
+    tasks {
+        register<TestCaseGeneratorTask>()
+        register<DependencyUpdateCleanTask> {
+            runAfterCleanTask()
+        }
+        if (params.createInfoClass) {
+            register<InAppInfoCreateTask> {
+                task("compile%sKotlin", variant).dependsOn(this)
+            }
+        }
+        if (params.buildTypeInLauncherIcon) {
+            register<LauncherIconTask> {
+                runAfterPreBuildTask()
+            }
+        }
+        if (params.updateDependencies) {
+            register<DependencyUpdateTask> {
+                runAfterAssembleTask()
+            }
+        }
+        register<ReleaseNotesCleanTask> {
+            runAfterCleanTask()
+        }
+        if (params.createReleaseNotes) {
+            register<ReleaseNotesCreateTask> {
+                runAfterAssembleTask()
+            }
+        }
+        register<ZipReleaseClearTask> {
+            runAfterCleanTask()
+        }
+        if (params.createReleaseZip) {
+            register<ZipReleaseCreateTask> {
+                if (params.createReleaseNotes) {
+                    runAfter<ReleaseNotesCreateTask>()
+                } else if (params.createDocumentation) {
+                    // todo
+//                    runAfter<DokkaCreateTask>()
+                } else {
+                    runAfterAssembleTask()
+                }
+            }
+        }
+        register<DokkaCleanTask> {
+            runAfterCleanTask()
+        }
+        if (params.createDocumentation) {
+            register<DokkaCreateTask> {
+                runAfterAssembleTask()
+            }
+        }
+        if (params.autoCorrectCode) {
+            register<DetektCleanTask> {
+                runAfterCleanTask()
+            }
+            register<DetektCreateTask> {
+                runAfterAssembleTask()
+            }
+        }
+    }
+    dependencies {
+//    ktlint(includeVersion = false)
+//    detekt(includeVersion = false)
+        mjdevTvLib()
+        baseDependencies()
+        composeDependencies()
+        daggerDependencies()
+        moshiDependencies()
+        okHttpDependencies()
+        timberDependencies()
+        retrofitDependencies()
+        sandwichDependencies()
+        glideDependencies()
+        exoPlayerDependencies()
+        encryptDependencies()
+        permissionsDependencies()
+        anotherDependencies()
+        widgetDependencies()
+        testDependencies()
+    }
+    test {
+        println("--------------------------------------------------------------------------------")
+        println(project.name.capitalize())
+        println("--------------------------------------------------------------------------------")
+        println("Version properties file = ${rootFile(params.versionPropertiesFile)}")
+        println("Signing properties file = ${rootFile(params.signingPropertiesFile)}")
+        println("--------------------------------------------------------------------------------")
+        println("Using signing config: ${params.signingConfigName}")
+        android.signingConfigs.forEach { config ->
+            println("Signing config key alias: ${config.name}")
+            println("Signing config key alias: ${config.keyAlias}")
+            println("Signing config store: ${config.storeFile}")
+        }
+        println("--------------------------------------------------------------------------------")
+        println("Version name: ${project.versionName}")
+        println("Version code: ${project.versionCode}")
+        println("--------------------------------------------------------------------------------")
+        println("Settings:")
+        println("--------------------------------------------------------------------------------")
+        println("Namespace = ${params.packageName}")
+        println("Application id = ${params.applicationId}")
+        println("Min sdk = ${params.minSdk}")
+        println("Compile sdk = ${params.compileSdk}")
+        println("MultiDex = ${params.multiDexEnabled}")
+        println("Create launcher icon by variant = ${params.buildTypeInLauncherIcon}")
+        println("Rename apk file by application id release zip archive = ${params.renameApkOutputByAppID}")
+        println("Update dependencies = ${params.updateDependencies}")
+        println("Autocorrect code = ${params.autoCorrectCode}")
+        println("Create info class = ${params.createInfoClass}")
+        println("Create release notes = ${params.createReleaseNotes}")
+        println("Create release zip archive = ${params.createReleaseZip}")
+        println("--------------------------------------------------------------------------------")
+    }
+    with(params) {
+        loadPropertiesFile(versionPropertiesFile)
+        finalizeDsl {
+            namespace = params.packageName
+            buildFeatures.compose = true
+            buildFeatures.buildConfig = true
+            buildFeatures.resValues = true
+            compileSdk = params.compileSdk
+            compileOptions.sourceCompatibility = JavaVersion.VERSION_17
+            compileOptions.targetCompatibility = JavaVersion.VERSION_17
+            composeOptions.kotlinCompilerExtensionVersion = "1.5.8"
+            loadKeyStoreProperties(signingPropertiesFile) { key, pwd, fileStore, pwdStore ->
+                signingConfigs {
+                    create(signingConfigName) {
+                        keyAlias = key
+                        keyPassword = pwd
+                        storeFile = fileStore
+                        storePassword = pwdStore
+                    }
+                }
+            }
+            packaging {
+                resources.excludes.apply {
+                    add("META-INF/")
+                    add("/META-INF/{AL2.0,LGPL2.1}")
+                    add("/META-INF/DEPENDENCIES")
+                    add("/mozilla/public-suffix-list.txt")
+                    add("okhttp3/")
+                    add("kotlin/")
+                    add("org/")
+                    add(".properties")
+                    add(".bin")
+                }
+            }
+            defaultConfig {
+                minSdk = params.minSdk
+                compileSdk = params.compileSdk
+                multiDexEnabled = params.multiDexEnabled
+                applicationId = params.applicationId
+                @Suppress("DEPRECATION")
+                buildConfigField(
+                    "String",
+                    "DATE_CREATED",
+                    "\"" + Date().toLocaleString() + "\""
+                )
+                proguardFiles(
+                    getDefaultProguardFile("proguard-android-optimize.txt"),
+                    "proguard-rules.pro"
+                )
+            }
+//            debug {
+//                applicationIdSuffix = ".debug"
+//                isDebuggable = true
+//                isMinifyEnabled = false
+//                isShrinkResources = false
+//                isCrunchPngs = false
+//                isEmbedMicroApp = true
+//            }
+//            release {
+//                applicationIdSuffix = ""
+//                isDebuggable = false
+//                isMinifyEnabled = true
+//                isShrinkResources = true
+//                isCrunchPngs = true
+//                isEmbedMicroApp = true
+//            }
+            lint {
+                checkReleaseBuilds = false
+                abortOnError = false
+                checkAllWarnings = true
+                warningsAsErrors = false
+                showAll = true
+                explainIssues = true
+                textReport = true
+                xmlReport = false
+                htmlReport = false
+                textOutput = rootProject.file("documentation").file("lint.txt")
+                checkTestSources = false
+                ignoreTestSources = true
+                checkGeneratedSources = false
+                checkDependencies = true
+            }
+        }
+        android {
+            // todo move
+//            defaultConfig {
+            // todo
+//                    versionCode = project.versionCode
+//                    versionName = project.versionName
+//                    signingConfig = signingConfigs[signingConfigName]
+//                multiDexEnabled = params.multiDexEnabled
+//            }
+            if (renameApkOutputByAppID) {
+                applicationVariants.all {
+                    outputs.map { output ->
+                        output as BaseVariantOutputImpl
+                    }.forEach { output ->
+                        output.outputFileName = "$applicationId-$versionName.apk"
+                    }
+                }
+            }
+        }
+    }
+})

+ 41 - 0
buildSrc/src/main/kotlin/org/mjdev/gradle/plugin/AppPluginParams.kt

@@ -0,0 +1,41 @@
+/*
+ *  Copyright (c) Milan Jurkulák 2024.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */
+
+package org.mjdev.gradle.plugin
+
+import org.mjdev.gradle.base.PluginExtension
+import org.mjdev.gradle.extensions.packageName
+
+@Suppress("RedundantVisibilityModifier")
+public open class AppPluginParams : PluginExtension("appConfig") {
+    public open var packageName = "org.mjdev.tvapp"
+
+    public open var applicationId = packageName
+
+    public open var minSdk = 21
+    public open var compileSdk = 34
+    public open var multiDexEnabled = true
+
+    public open var versionPropertiesFile = CONFIG_VERSION_PROPERTIES_FILE
+    public open var signingPropertiesFile = CONFIG_KEYSTORE_PROPERTIES_FILE
+    public open var signingConfigName = SIGNING_CONFIG_NAME
+    public open var createDocumentation = false
+    public open var createReleaseNotes = false
+    public open var updateDependencies = false
+    public open var autoCorrectCode = false
+    public open var createReleaseZip = false
+    public open var renameApkOutputByAppID = false
+    public open var createInfoClass = false
+    public open var buildTypeInLauncherIcon = false
+
+    companion object {
+        const val CONFIG_VERSION_PROPERTIES_FILE = "config/version.properties"
+        const val CONFIG_KEYSTORE_PROPERTIES_FILE = "config/keystore.properties"
+        const val SIGNING_CONFIG_NAME = "Any"
+    }
+}

+ 0 - 41
buildSrc/src/main/kotlin/org/mjdev/gradle/plugin/DependencyUpdatePlugin.kt

@@ -1,41 +0,0 @@
-/*
- *  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.gradle.plugin
-
-//import com.github.benmanes.gradle.versions.updates.DependencyUpdatesTask
-import org.gradle.api.Plugin
-import org.gradle.api.Project
-import java.util.*
-
-class DependencyUpdatePlugin : Plugin<Project> {
-    override fun apply(project: Project) = with(project) {
-//        tasks.named("dependencyUpdates", DependencyUpdatesTask::class.java).configure {
-//            rejectVersionIf {
-//                isNonStable(candidate.version)
-//            }
-//            outputFormatter = "html"
-//            doLast {
-//                exec {
-//                    commandLine("open", "build/dependencyUpdates/report.html")
-//                }
-//            }
-//        }
-    }
-
-    private fun isNonStable(version: String): Boolean {
-        val stableKeyword = listOf("RELEASE", "FINAL", "GA").any {
-            version.uppercase(Locale.getDefault()).contains(it)
-        }
-        val regex = "^[0-9,.v-]+(-r)?$".toRegex()
-        val isStable = stableKeyword || regex.matches(version)
-        return isStable.not()
-    }
-}

+ 23 - 0
buildSrc/src/main/kotlin/org/mjdev/gradle/plugin/LibPlugin.kt

@@ -0,0 +1,23 @@
+/*
+ *  Copyright (c) Milan Jurkulák 2024.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */
+
+@file:Suppress("UsePropertyAccessSyntax")
+
+package org.mjdev.gradle.plugin
+
+import com.android.build.gradle.LibraryExtension
+import org.mjdev.gradle.base.PluginBase
+
+@Suppress("unused", "MemberVisibilityCanBePrivate")
+class LibPlugin : PluginBase<LibPluginParams, LibraryExtension>(LibPluginParams::class.java, {
+    test {
+//        println("Project variant: ${project.currentVariant}")
+    }
+    with(params) {
+    }
+})

+ 14 - 0
buildSrc/src/main/kotlin/org/mjdev/gradle/plugin/LibPluginParams.kt

@@ -0,0 +1,14 @@
+/*
+ *  Copyright (c) Milan Jurkulák 2024.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */
+
+package org.mjdev.gradle.plugin
+
+import org.mjdev.gradle.base.PluginExtension
+
+@Suppress("RedundantVisibilityModifier")
+public open class LibPluginParams : PluginExtension("libConfig")

+ 0 - 173
buildSrc/src/main/kotlin/org/mjdev/gradle/plugin/MainAppPlugin.kt

@@ -1,173 +0,0 @@
-/*
- * Copyright (c) Milan Jurkulák 2023.
- * Contact:
- * e: mimoccc@gmail.com
- * e: mj@mjdev.org
- * w: https://mjdev.org
- */
-
-package org.mjdev.gradle.plugin
-
-import org.gradle.api.JavaVersion
-import org.gradle.api.Plugin
-import org.gradle.api.Project
-import org.gradle.api.provider.Property
-import org.gradle.api.tasks.Exec
-import org.gradle.kotlin.dsl.extra
-import org.mjdev.gradle.extensions.containsAndroidPlugin
-import java.io.File
-import java.io.FileInputStream
-import java.util.Properties
-import org.mjdev.gradle.extensions.dokkaGfm
-import org.mjdev.gradle.extensions.versionName
-import org.mjdev.gradle.extensions.registerTask
-
-const val CONFIG_VERSION_PROPERTIES_FILE = "config/version.properties"
-const val CONFIG_KEYSTORE_PROPERTIES_FILE = "config/keystore.properties"
-
-const val SIGNING_CONFIG_NAME = "Any"
-
-const val projectCompileSdk: Int = 34
-const val projectMinSdk = 21
-
-const val kotlinCompilerExtVersion = "1.5.1"
-
-val javaVersion: JavaVersion = JavaVersion.VERSION_17
-
-const val TASK_PREPARE_RELEASE_NOTES = "prepareReleaseNotes"
-
-const val TASK_ASSEMBLE_RELEASE = "assembleRelease"
-const val TASK_ASSEMBLE_DEBUG = "assembleDebug"
-const val TASK_ASSEMBLE_MINIFIED = "assembleMinified"
-const val TASK_ASSEMBLE_MOCK = "assembleMock"
-
-val ASSEMBLE_TASKS = listOf(
-    TASK_ASSEMBLE_DEBUG,
-    TASK_ASSEMBLE_MOCK,
-    TASK_ASSEMBLE_RELEASE,
-    TASK_ASSEMBLE_MINIFIED
-)
-
-@Suppress("unused")
-interface MainAppPluginExtension {
-    val versionPropertiesFile: Property<String?>
-    val signingPropertiesFile: Property<String?>
-    val signingConfigName: Property<String?>
-    val createDocumentation: Property<Boolean>
-    val runDetekt: Property<Boolean>
-}
-
-// todo
-@Suppress("unused", "MemberVisibilityCanBePrivate")
-abstract class MainAppPlugin : Plugin<Project> {
-
-    @Suppress("UNUSED_VARIABLE")
-    override fun apply(project: Project): Unit = with(project) {
-
-        val mainExt = extensions.create("mainAppConfig", MainAppPluginExtension::class.java)
-
-//        configure<DetektExtension> {
-//            autoCorrect = true
-//            toolVersion = "1.20.0"
-//            parallel = false
-//            source = files(
-//                "src/main/kotlin",
-//                "src/main/java"
-//            )
-//            config = files("${project.rootDir}/buildSrc/detekt.yml")
-//        }
-
-        // todo non functional as wanted
-//        allprojects {
-//            gradle.useLogger(CustomEventLogger())
-//        }
-
-        // versions
-        val versionPropertiesFile = rootProject.file(CONFIG_VERSION_PROPERTIES_FILE)
-        val versionProps = Properties()
-        versionProps.load(FileInputStream(versionPropertiesFile))
-        versionProps.forEach { prop ->
-            rootProject.extra.set(prop.key.toString(), prop.value.toString())
-        }
-
-        //task release notes
-        registerTask<Exec>(TASK_PREPARE_RELEASE_NOTES) {
-            commandLine = mutableListOf("./scripts/git_log.sh")
-            workingDir = rootDir
-            setArgs(mutableListOf(versionName))
-        }
-
-        // documentation
-        allprojects {
-            afterEvaluate {
-                ASSEMBLE_TASKS.forEach { taskName ->
-                    val releaseNotesTask = tasks.findByName(TASK_PREPARE_RELEASE_NOTES)
-                    val currentTask = tasks.findByName(taskName)
-                    releaseNotesTask?.apply {
-                        outputs.dir(File(projectDir, "../wiki/documentation"))
-                    }
-                    if (containsAndroidPlugin) {
-                    currentTask?.finalizedBy(dokkaGfm, releaseNotesTask)
-                    } else {
-                        currentTask?.finalizedBy(dokkaGfm)
-                    }
-                }
-            }
-        }
-
-//        android?.finalizeDsl { ext->
-//            ext.buildTypes.create("staging").let { buildType ->
-//                buildType.initWith(ext.buildTypes.getByName("debug"))
-//                buildType.manifestPlaceholders["hostName"] = "example.com"
-//                buildType.applicationIdSuffix = ".debugStaging"
-//            }
-//        }
-
-//        android?.applicationVariants.all {
-//            outputs.map {
-//                it as BaseVariantOutputImpl
-//            }.forEach { output ->
-//                val outputFileName = "${applicationId}-${versionName}.apk"
-//                output.outputFileName = outputFileName
-//            }
-//        }
-
-//        // dependencies
-//        dependencies.apply {
-//            // base libs kotlin & etc
-//            implementCore()
-//            // lifecycle
-//            implementLifeCycle()
-//            // coroutines
-//            implementCoroutines()
-//            // dagger core
-//            implementDagger()
-//            // dagger - hilt
-//            implementHilt()
-//            // themes
-//            implementThemeMaterial3()
-//            // compose
-//            implementCompose()
-//            // glide
-//            implementGlide()
-//            // okhttp
-//            implementOkHttp()
-//            // retrofit
-//            implementRetrofit()
-//            // moshi
-//            implementMoshi()
-//            // log
-//            implementTimber()
-//            // db
-//            implementORMLite()
-//            // test
-//            implementTestUnit()
-//            // ui test
-//            implementTestInstrumentation()
-//            // debug
-//            implementDebug()
-//        }
-
-    }
-
-}

+ 35 - 0
buildSrc/src/main/kotlin/org/mjdev/gradle/tasks/DependencyUpdateCleanTask.kt

@@ -0,0 +1,35 @@
+/*
+ *  Copyright (c) Milan Jurkulák 2024.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */
+
+package org.mjdev.gradle.tasks
+
+import org.mjdev.gradle.base.BaseTask
+import org.gradle.api.tasks.TaskAction
+
+open class DependencyUpdateCleanTask : BaseTask() {
+
+    private val outputFiles = listOf(
+        project.rootProject.file("dependencies.txt")
+    )
+
+    init {
+        group = "mjdev"
+        description = "This task clear all release-notes files, to clean project."
+        outputs.upToDateWhen { false }
+    }
+
+    @TaskAction
+    fun taskAction() {
+        println("> Clearing release zip archive") // todo
+        outputFiles.forEach { file ->
+            if (file.isDirectory) file.deleteRecursively()
+            else file.delete()
+        }
+    }
+
+}

+ 34 - 0
buildSrc/src/main/kotlin/org/mjdev/gradle/tasks/DependencyUpdateTask.kt

@@ -0,0 +1,34 @@
+/*
+ *  Copyright (c) Milan Jurkulák 2024.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */
+
+package org.mjdev.gradle.tasks
+
+import org.mjdev.gradle.versions.base.DependencyUpdatesTask
+import java.io.File
+
+open class DependencyUpdateTask : DependencyUpdatesTask() {
+
+    private val dependenciesOutputDir: File = project.rootProject.rootDir
+    private val reportName: String = "dependencies"
+    private val outputFormat = "text"
+
+
+    init {
+        group = "mjdev"
+        description = "Displays the dependency updates for the project."
+        outputDir = dependenciesOutputDir.absolutePath
+        reportFileName = reportName
+        outputFormatter = outputFormat
+        checkForGradleUpdate = false
+        checkConstraints = false
+        checkBuildEnvironmentConstraints = false
+        resolutionStrategy = null
+//        outputs.upToDateWhen { false }
+    }
+
+}

+ 28 - 0
buildSrc/src/main/kotlin/org/mjdev/gradle/tasks/DetektCleanTask.kt

@@ -0,0 +1,28 @@
+/*
+ *  Copyright (c) Milan Jurkulák 2024.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */
+
+package org.mjdev.gradle.tasks
+
+import org.gradle.api.tasks.TaskAction
+import org.mjdev.gradle.base.BaseTask
+
+// todo
+open class DetektCleanTask : BaseTask() {
+
+    init {
+        group = "mjdev"
+        description = "Kotlin lint clean task."
+        outputs.upToDateWhen { false }
+    }
+
+    @TaskAction
+    fun taskAction() {
+        // todo
+    }
+
+}

+ 45 - 0
buildSrc/src/main/kotlin/org/mjdev/gradle/tasks/DetektCreateTask.kt

@@ -0,0 +1,45 @@
+/*
+ *  Copyright (c) Milan Jurkulák 2024.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */
+
+package org.mjdev.gradle.tasks
+
+import org.gradle.api.tasks.TaskAction
+import org.mjdev.gradle.base.BaseTask
+
+
+open class DetektCreateTask : BaseTask() {
+
+    private val documentationDir = "documentation"
+
+    init {
+        group = "mjdev"
+        description = "Kotlin code check and format task."
+
+//        tasks.byType<DetektGenerateConfigTask>().apply {
+//            runAfter(this)
+//        }
+//        tasks.byName<Task>("ktlintFormat").apply {
+//            runAfter(cleanTask)
+//        }
+
+        outputs.upToDateWhen { false }
+    }
+
+    @TaskAction
+    fun taskAction() {
+//        applyPlugin("org.jlleitschuh.gradle.ktlint")
+//        configureExtension<DetektExtension> {
+//            autoCorrect = true
+//            parallel = false
+//
+//            source = project.files("src/main/kotlin")
+//            reportsDir = project.rootProject.file(documentationDir)
+//        }
+    }
+
+}

+ 35 - 0
buildSrc/src/main/kotlin/org/mjdev/gradle/tasks/DokkaCleanTask.kt

@@ -0,0 +1,35 @@
+/*
+ *  Copyright (c) Milan Jurkulák 2024.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */
+
+package org.mjdev.gradle.tasks
+
+import org.gradle.api.tasks.TaskAction
+import java.io.File
+import org.mjdev.gradle.extensions.file
+import org.mjdev.gradle.extensions.projectName
+import org.mjdev.gradle.base.BaseTask
+
+open class DokkaCleanTask : BaseTask() {
+
+    private val docProjectDir: File
+        get() =  project.rootProject
+            .file("documentation")
+            .file(projectName)
+
+    init {
+        group = "mjdev"
+        description = "Documentation clean task."
+        outputs.upToDateWhen { false }
+    }
+
+    @TaskAction
+    fun taskAction() {
+        project.rootProject.file(docProjectDir).deleteRecursively()
+    }
+
+}

+ 45 - 0
buildSrc/src/main/kotlin/org/mjdev/gradle/tasks/DokkaCreateTask.kt

@@ -0,0 +1,45 @@
+/*
+ *  Copyright (c) Milan Jurkulák 2024.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */
+
+package org.mjdev.gradle.tasks
+
+import org.jetbrains.dokka.gradle.DokkaTask
+import org.mjdev.gradle.extensions.file
+import org.mjdev.gradle.extensions.projectName
+import java.io.File
+
+open class DokkaCreateTask : DokkaTask() {
+
+    private val documentationDir: File
+        get() = project.rootProject.file("documentation")
+
+    private val docProjectDir: File
+        get() = documentationDir
+            .file(projectName)
+            .file("documentation")
+
+    init {
+        group = "mjdev"
+        description = "Documentation create task."
+
+        outputDirectory = docProjectDir.absolutePath
+        sourceDirs = listOf(File(project.projectDir.absolutePath))
+        outputFormat = "gfm"
+        includeNonPublic = true
+        reportUndocumented = true
+        sourceRoot {
+            path = File(project.rootDir, "src/main/kotlin").absolutePath
+        }
+        skipEmptyPackages = false
+        collectInheritedExtensionsFromLibraries = false
+        outputFormat = "gfm"
+
+        outputs.upToDateWhen { false }
+    }
+
+}

Some files were not shown because too many files changed in this diff