Quellcode durchsuchen

code improvements

code improvements

code improvements
milan jurkulák vor 2 Jahren
Ursprung
Commit
142f88049f
100 geänderte Dateien mit 4414 neuen und 1352 gelöschten Zeilen
  1. 4 4
      .github/workflows/build-gradle-project.yml
  2. 0 24
      .run/tvapp [ktlint].run.xml
  3. 0 21
      SECURITY.md
  4. 126 218
      app/build.gradle.kts
  5. 149 83
      app/objectbox-models/default.json
  6. 22 8
      app/objectbox-models/default.json.bak
  7. 36 11
      app/src/main/AndroidManifest.xml
  8. 15 1
      app/src/main/assets/adblock.txt
  9. 12 2
      app/src/main/kotlin/org/mjdev/tvapp/activity/IPTVActivity.kt
  10. 1 0
      app/src/main/kotlin/org/mjdev/tvapp/activity/MainActivity.kt
  11. 18 10
      app/src/main/kotlin/org/mjdev/tvapp/activity/WebActivity.kt
  12. 12 5
      app/src/main/kotlin/org/mjdev/tvapp/app/Application.kt
  13. 86 0
      app/src/main/kotlin/org/mjdev/tvapp/cast/CastOptionsProvider.kt
  14. 222 0
      app/src/main/kotlin/org/mjdev/tvapp/cast/CastService.kt
  15. 41 0
      app/src/main/kotlin/org/mjdev/tvapp/cast/CastSessionTransferCallback.kt
  16. 13 0
      app/src/main/kotlin/org/mjdev/tvapp/data/events/PauseEvent.kt
  17. 13 0
      app/src/main/kotlin/org/mjdev/tvapp/data/events/SyncEnded.kt
  18. 16 0
      app/src/main/kotlin/org/mjdev/tvapp/data/events/SyncEvent.kt
  19. 13 0
      app/src/main/kotlin/org/mjdev/tvapp/data/events/SyncStarted.kt
  20. 11 2
      app/src/main/kotlin/org/mjdev/tvapp/data/local/Media.kt
  21. 37 0
      app/src/main/kotlin/org/mjdev/tvapp/data/local/ParsedLink.kt
  22. 1 1
      app/src/main/kotlin/org/mjdev/tvapp/data/local/TVChannel.kt
  23. 2 1
      app/src/main/kotlin/org/mjdev/tvapp/data/remote/Epg.kt
  24. 2 1
      app/src/main/kotlin/org/mjdev/tvapp/data/remote/Stream.kt
  25. 13 4
      app/src/main/kotlin/org/mjdev/tvapp/database/DAO.kt
  26. 10 11
      app/src/main/kotlin/org/mjdev/tvapp/module/ProvideModule.kt
  27. 58 12
      app/src/main/kotlin/org/mjdev/tvapp/sync/SyncAdapter.kt
  28. 15 1
      app/src/main/kotlin/org/mjdev/tvapp/sync/SyncService.kt
  29. 22 0
      app/src/main/kotlin/org/mjdev/tvapp/sync/SyncStarter.kt
  30. 9 7
      app/src/main/kotlin/org/mjdev/tvapp/sync/base/SyncItems.kt
  31. 54 2
      app/src/main/kotlin/org/mjdev/tvapp/sync/base/Syncer.kt
  32. 2 2
      app/src/main/kotlin/org/mjdev/tvapp/sync/provider/DataProvider.kt
  33. 14 5
      app/src/main/kotlin/org/mjdev/tvapp/sync/syncers/GithubMoviesSyncer.kt
  34. 0 42
      app/src/main/kotlin/org/mjdev/tvapp/sync/syncers/WebMovieSyncer.kt
  35. 3 3
      app/src/main/kotlin/org/mjdev/tvapp/ui/pages/AboutPage.kt
  36. 59 34
      app/src/main/kotlin/org/mjdev/tvapp/ui/pages/MainPage.kt
  37. 2 2
      app/src/main/kotlin/org/mjdev/tvapp/ui/pages/PluginsPage.kt
  38. 2 2
      app/src/main/kotlin/org/mjdev/tvapp/ui/pages/SubscriptionPage.kt
  39. 3 0
      app/src/main/kotlin/org/mjdev/tvapp/ui/screens/GalleryScreen.kt
  40. 22 8
      app/src/main/kotlin/org/mjdev/tvapp/ui/screens/IPTVScreen.kt
  41. 3 12
      app/src/main/kotlin/org/mjdev/tvapp/ui/screens/LoadingScreen.kt
  42. 1 0
      app/src/main/kotlin/org/mjdev/tvapp/ui/screens/MainScreen.kt
  43. 12 13
      app/src/main/kotlin/org/mjdev/tvapp/ui/screens/SplashScreen.kt
  44. 12 23
      app/src/main/kotlin/org/mjdev/tvapp/ui/screens/WebScreen.kt
  45. 2 2
      app/src/main/kotlin/org/mjdev/tvapp/viewmodel/DetailViewModel.kt
  46. 3 3
      app/src/main/kotlin/org/mjdev/tvapp/viewmodel/IPTVViewModel.kt
  47. 43 47
      app/src/main/kotlin/org/mjdev/tvapp/viewmodel/MainViewModel.kt
  48. 1 0
      app/src/main/kotlin/org/mjdev/tvapp/widget/MainAppWidget.kt
  49. 2 2
      app/src/main/kotlin/org/mjdev/tvapp/widget/MyAppWidgetReceiver.kt
  50. 2 2
      app/src/main/kotlin/org/mjdev/tvapp/widget/PlayPauseAction.kt
  51. 2 2
      app/src/main/kotlin/org/mjdev/tvapp/widget/RefreshAction.kt
  52. BIN
      app/src/main/res/drawable/background.png
  53. 19 36
      build.gradle.kts
  54. 48 46
      buildSrc/build.gradle.kts
  55. 15 0
      buildSrc/settings.gradle.kts
  56. 98 0
      buildSrc/src/main/kotlin/org/mjdev/gradle/base/BasePlugin.kt
  57. 82 0
      buildSrc/src/main/kotlin/org/mjdev/gradle/base/BaseTask.kt
  58. 0 272
      buildSrc/src/main/kotlin/org/mjdev/gradle/dependency/DependencyHandler.kt
  59. 17 0
      buildSrc/src/main/kotlin/org/mjdev/gradle/extensions/DateExt.kt
  60. 20 0
      buildSrc/src/main/kotlin/org/mjdev/gradle/extensions/ExtFileFilter.kt
  61. 117 0
      buildSrc/src/main/kotlin/org/mjdev/gradle/extensions/File.kt
  62. 18 0
      buildSrc/src/main/kotlin/org/mjdev/gradle/extensions/HashMapExt.kt
  63. 87 0
      buildSrc/src/main/kotlin/org/mjdev/gradle/extensions/KotlinPoetExt.kt
  64. 0 46
      buildSrc/src/main/kotlin/org/mjdev/gradle/extensions/PluginDependenciesSpecScope.kt
  65. 183 74
      buildSrc/src/main/kotlin/org/mjdev/gradle/extensions/Project.kt
  66. 31 11
      buildSrc/src/main/kotlin/org/mjdev/gradle/extensions/SigningConfigs.kt
  67. 82 0
      buildSrc/src/main/kotlin/org/mjdev/gradle/extensions/String.kt
  68. 91 0
      buildSrc/src/main/kotlin/org/mjdev/gradle/extensions/SysUtils.kt
  69. 267 0
      buildSrc/src/main/kotlin/org/mjdev/gradle/extensions/TaskExt.kt
  70. 46 6
      buildSrc/src/main/kotlin/org/mjdev/gradle/extensions/VariantDimension.kt
  71. 30 0
      buildSrc/src/main/kotlin/org/mjdev/gradle/icons/LauncherIcon.kt
  72. 150 0
      buildSrc/src/main/kotlin/org/mjdev/gradle/icons/LauncherIconConfig.kt
  73. 64 0
      buildSrc/src/main/kotlin/org/mjdev/gradle/icons/LauncherIconExtension.kt
  74. 112 0
      buildSrc/src/main/kotlin/org/mjdev/gradle/icons/LauncherIconPlugin.kt
  75. 95 0
      buildSrc/src/main/kotlin/org/mjdev/gradle/icons/LauncherIconTask.kt
  76. 145 0
      buildSrc/src/main/kotlin/org/mjdev/gradle/icons/filter/ColorRibbonFilter.kt
  77. 37 0
      buildSrc/src/main/kotlin/org/mjdev/gradle/icons/filter/GrayscaleFilter.kt
  78. 8 0
      buildSrc/src/main/kotlin/org/mjdev/gradle/icons/filter/LauncherIconFilter.kt
  79. 48 0
      buildSrc/src/main/kotlin/org/mjdev/gradle/icons/filter/OverlayFilter.kt
  80. 49 0
      buildSrc/src/main/kotlin/org/mjdev/gradle/icons/utils/Resources.kt
  81. 0 14
      buildSrc/src/main/kotlin/org/mjdev/gradle/libs/Libs.kt
  82. 248 0
      buildSrc/src/main/kotlin/org/mjdev/gradle/plugin/AppPlugin.kt
  83. 0 41
      buildSrc/src/main/kotlin/org/mjdev/gradle/plugin/DependencyUpdatePlugin.kt
  84. 294 0
      buildSrc/src/main/kotlin/org/mjdev/gradle/plugin/LibPlugin.kt
  85. 0 173
      buildSrc/src/main/kotlin/org/mjdev/gradle/plugin/MainAppPlugin.kt
  86. 17 0
      buildSrc/src/main/kotlin/org/mjdev/gradle/plugin/config/AppConfig.kt
  87. 17 0
      buildSrc/src/main/kotlin/org/mjdev/gradle/plugin/config/LibConfig.kt
  88. 28 0
      buildSrc/src/main/kotlin/org/mjdev/gradle/tasks/DetektCleanTask.kt
  89. 45 0
      buildSrc/src/main/kotlin/org/mjdev/gradle/tasks/DetektCreateTask.kt
  90. 35 0
      buildSrc/src/main/kotlin/org/mjdev/gradle/tasks/DokkaCleanTask.kt
  91. 44 0
      buildSrc/src/main/kotlin/org/mjdev/gradle/tasks/DokkaCreateTask.kt
  92. 56 0
      buildSrc/src/main/kotlin/org/mjdev/gradle/tasks/InAppInfoCreateTask.kt
  93. 51 0
      buildSrc/src/main/kotlin/org/mjdev/gradle/tasks/ReleaseNotesCleanTask.kt
  94. 87 0
      buildSrc/src/main/kotlin/org/mjdev/gradle/tasks/ReleaseNotesCreateTask.kt
  95. 46 0
      buildSrc/src/main/kotlin/org/mjdev/gradle/tasks/TestCaseGeneratorTask.kt
  96. 93 0
      buildSrc/src/main/kotlin/org/mjdev/gradle/tasks/TestClassesGeneratorTask.kt
  97. 38 0
      buildSrc/src/main/kotlin/org/mjdev/gradle/tasks/ZipReleaseClearTask.kt
  98. 47 0
      buildSrc/src/main/kotlin/org/mjdev/gradle/tasks/ZipReleaseCreateTask.kt
  99. 16 0
      buildSrc/src/main/kotlin/org/mjdev/gradle/unittest/ClassHelper.kt
  100. 70 0
      buildSrc/src/main/kotlin/org/mjdev/gradle/unittest/ClassHelperImpl.kt

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

@@ -8,7 +8,7 @@ on:
 jobs:
   build-gradle-project:
     runs-on: ubuntu-latest
-#permissions
+# permissions
     permissions:
       contents: write
       deployments: write
@@ -38,7 +38,7 @@ jobs:
       - name: Prepare Version Number
         id: prepare_version
         run: |
-          export GRADLE_PATH="./config/version.properties";
+          export GRADLE_PATH="./config/version.prop";
           export GRADLE_FIELD_VMAJ="majorVersion"
           export GRADLE_FIELD_VMIN="minorVersion"
           export GRADLE_FIELD_VPAT="patchVersion"
@@ -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-$APP_VERSION.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

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

@@ -1,24 +0,0 @@
-<component name="ProjectRunConfigurationManager">
-  <configuration default="false" name="tvapp [ktlint]" type="GradleRunConfiguration" factoryName="Gradle" nameIsGenerated="true">
-    <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="ktlintFormat" />
-        </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.

+ 126 - 218
app/build.gradle.kts

@@ -1,228 +1,136 @@
 /*
- * 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("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
-
-    signingConfigs {
-        loadKeyStoreProperties(
-            CONFIG_KEYSTORE_PROPERTIES_FILE
-        ) { aliasKey, passwordKey, fileStore, passwordStore ->
-            create(SIGNING_CONFIG_NAME) {
-                keyAlias = aliasKey
-                keyPassword = passwordKey
-                storeFile = fileStore
-                storePassword = passwordStore
-            }
-        }
-    }
-
-    defaultConfig {
-        applicationId = "org.mjdev.tvapp"
-
-        minSdk = projectMinSdk
-        targetSdk = projectCompileSdk
-
-        versionCode = project.versionCode
-        versionName = project.versionName
-
-        signingConfig = signingConfigs[SIGNING_CONFIG_NAME]
-
-        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
-            isMinifyEnabled = false
-            isShrinkResources = false
-            isCrunchPngs = false
-            isEmbedMicroApp = true
-            proguardFiles(
-                getDefaultProguardFile("proguard-android-optimize.txt"),
-                "proguard-rules.pro"
-            )
-            buildConfigString(
-                "SYNC_AUTH" to "${defaultConfig.applicationId}$applicationIdSuffix.sync"
-            )
-            stringRes(
-                "sync_auth" to "${defaultConfig.applicationId}$applicationIdSuffix.sync",
-                "app_name" to "TV App ${applicationIdSuffix.suffixToString()}"
-            )
-        }
-
-        release {
-            applicationIdSuffix = ""
-            isDebuggable = false
-            isMinifyEnabled = true
-            isShrinkResources = true
-            isCrunchPngs = true
-            isEmbedMicroApp = true
-            proguardFiles(
-                getDefaultProguardFile("proguard-android-optimize.txt"),
-                "proguard-rules.pro"
-            )
-            buildConfigString(
-                "SYNC_AUTH" to "${defaultConfig.applicationId}$applicationIdSuffix.sync"
-            )
-            stringRes(
-                "sync_auth" to "${defaultConfig.applicationId}$applicationIdSuffix.sync",
-                "app_name" to "TV App ${applicationIdSuffix.suffixToString()}"
-            )
-        }
-    }
-
-    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
-    }
+// todo
+appConfig {
+    autoCorrectCode = false
+    createDocumentation = true
+//    createReleaseNotes = true
+//    renameApkOutputByAppID = false
+//    createReleaseZip = true
+//    createInfoClass = false
+//    buildTypeInLauncherIcon = false
 }
 
 dependencies {
-    mjdevTvLib()
-    baseDependencies()
-    composeDependencies()
-    daggerDependencies()
-    moshiDependencies()
-    okHttpDependencies()
-    timberDependencies()
-    retrofitDependencies()
-    sandwichDependencies()
-    glideDependencies()
-//    imageIODependencies()
-    exoPlayerDependencies()
-    encryptDependencies()
-    permissionsDependencies()
-    anotherDependencies()
-    widgetDependencies()
-    testDependencies()
+    implementation(project(mapOf("path" to ":tvlib")))
+
+    // compose
+    implementation(platform(libs.androidx.compose.bom))
+    implementation(libs.androidx.compose.ui.tooling)
+    implementation(libs.androidx.compose.activity)
+    // more icons
+    implementation(libs.androidx.material.icons.extended)
+    // tv compose
+    implementation(libs.androidx.tv.foundation)
+    implementation(libs.androidx.tv.material)
+    // view model
+    implementation(libs.androidx.compose.lifecycle.viewmodel)
+    // navigation
+    implementation(libs.androidx.compose.navigation)
+    // todo remove, dynamic background & colors
+    implementation(libs.androidx.material3)
+    // previews
+    debugImplementation(libs.androidx.customview.poolingcontainer)
+    // foundation
+    implementation(libs.androidx.foundation)
+    // dagger
+    implementation(libs.dagger)
+    // dagger android
+    implementation(libs.dagger.android)
+    implementation(libs.dagger.android.support)
+    // dagger hilt
+    implementation(libs.dagger.hilt.android)
+    implementation(libs.androidx.compose.hilt.navigation)
+    // dagger annotations
+    kapt(libs.dagger.compiler)
+    kapt(libs.dagger.android.processor)
+    kapt(libs.dagger.hilt.compiler)
+    implementation(libs.moshi)
+    implementation(libs.moshi.retrofit.converter)
+    ksp(libs.moshi.kotlin.codegen)
+    // okhttp
+    implementation(platform(libs.okhttp3.bom))
+    implementation(libs.okhttp3)
+    implementation(libs.okhttp3.logging.interceptor)
+    // timber
+    implementation(libs.timber)
+    // retrofit
+    implementation(libs.retrofit)
+    implementation(libs.retrofit2.kotlin.coroutines.adapter)
+    // sandwich
+    implementation(libs.sandwich)
+    implementation(libs.sandwich.retrofit)
+    // glide
+    implementation(libs.glide.okhttp3.integration)
+    implementation(libs.glide.compose)
+    ksp(libs.glide.ksp)
+    // landscapist
+    implementation(libs.landscapist.glide)
+    implementation(libs.landscapist.transformation)
+    implementation(libs.landscapist.palette)
+    implementation(libs.landscapist.placeholder)
+    // exif
+    implementation(libs.androidx.exifinterface)
+    // exoPlayer
+    implementation(libs.androidx.media3.exoplayer)
+    implementation(libs.androidx.media3.exoplayer.dash)
+    implementation(libs.androidx.media3.exoplayer.hls)
+    implementation(libs.androidx.media3.exoplayer.rtsp)
+    implementation(libs.androidx.media3.datasource.cronet)
+    implementation(libs.androidx.media3.datasource.okhttp)
+    implementation(libs.androidx.media3.datasource.rtmp)
+    implementation(libs.androidx.media3.ui)
+    implementation(libs.androidx.media3.session)
+    implementation(libs.androidx.media3.extractor)
+    implementation(libs.androidx.media3.cast)
+    implementation(libs.androidx.media3.exoplayer.workmanager)
+    implementation(libs.androidx.media3.transformer)
+    implementation(libs.androidx.media3.database)
+    implementation(libs.androidx.media3.decoder)
+    implementation(libs.androidx.media3.datasource)
+    implementation(libs.androidx.media3.common)
+    implementation(libs.androidx.media3.exoplayer.ima)
+    implementation(libs.androidx.media3.ui.leanback)
+    // encrypt
+    implementation(libs.aescrypt)
+    // permission
+    implementation(libs.accompanist.permissions)
+    // another
+    // zxing
+    implementation(libs.zxing.core)
+    // svg
+    implementation(libs.androidsvg.aar)
+    // pallette
+    implementation(libs.androidx.palette.ktx)
+    // lottie
+    implementation(libs.compose.lottie)
+    // jsoup
+    implementation(libs.jsoup)
+    // dm
+    implementation(libs.dailymotion.sdk.android)
+    // dynamic theme
+    implementation(libs.android.material)
+    // widget
+    implementation(libs.androidx.glance.appwidget)
+    implementation(libs.androidx.glance.material)
+    implementation(libs.androidx.glance.material3)
+    // test
+    testImplementation(libs.junit)
+    androidTestImplementation(libs.androidx.junit)
+    androidTestImplementation(libs.androidx.espresso.core)
+    // anr
+    implementation(libs.anrwatchdog)
+    // oauth
+    implementation(libs.auth0)
+    implementation(libs.android.jwtdecode)
 }

+ 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,

+ 36 - 11
app/src/main/AndroidManifest.xml

@@ -17,18 +17,23 @@
     <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"
         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" />
@@ -39,7 +44,6 @@
     <uses-feature
         android:name="android.hardware.touchscreen"
         android:required="false" />
-
     <uses-feature
         android:name="android.software.leanback"
         android:required="false" />
@@ -61,6 +65,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 +76,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 +112,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 +136,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 +149,7 @@
                 <category android:name="android.intent.category.DEFAULT" />
             </intent-filter>
 
-            <!--ebug-->
+            <!--Debug-->
 
             <intent-filter>
                 <action android:name="android.intent.action.MAIN" />
@@ -209,6 +217,10 @@
 
         </service>
 
+        <service
+            android:name=".cast.CastService"
+            android:exported="false" />
+
         <provider
             android:name=".sync.provider.DataProvider"
             android:authorities="@string/sync_auth"
@@ -217,6 +229,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 +251,11 @@
 
         </receiver>
 
+        <receiver
+            android:name="androidx.mediarouter.media.MediaTransferReceiver"
+            android:exported="true"
+            tools:ignore="ExportedReceiver" />
+
     </application>
 
 </manifest>

+ 15 - 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,13 @@ 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)

+ 43 - 47
app/src/main/kotlin/org/mjdev/tvapp/viewmodel/MainViewModel.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.viewmodel
@@ -13,31 +13,23 @@ 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
@@ -47,6 +39,10 @@ class MainViewModel @Inject constructor(
         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


+ 19 - 36
build.gradle.kts

@@ -1,54 +1,37 @@
 /*
- * 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("org.jlleitschuh.gradle.ktlint") version "11.3.2" apply true
-    id("org.jetbrains.dokka") version "1.8.10" apply true
-    // todo ktorfit https://foso.github.io/Ktorfit/
-//    id("de.jensklingenberg.ktorfit") version "1.10.2" apply true
+    alias(libs.plugins.android.dagger.hilt) apply false
+    alias(libs.plugins.devtools.google.ksp) apply false
+    alias(libs.plugins.gradle.ktlint) apply false
 }
 
 buildscript {
-
     repositories {
         mavenCentral()
-        maven(url = "https://storage.googleapis.com/r8-releases/raw")
-        maven(url = "https://mvn.dailymotion.com/repository/releases/")
+        maven("https://storage.googleapis.com/r8-releases/raw")
+        maven("https://mvn.dailymotion.com/repository/releases/")
         google()
     }
-
     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("io.objectbox:objectbox-gradle-plugin:3.7.1")
-//        classpath("com.github.ben-manes:gradle-versions-plugin:0.42.0")
-//        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")
+        classpath(libs.gradle)
+        classpath(libs.gradle.api)
+        classpath(libs.gradle.kotlin.plugin)
+        classpath(libs.gradle.tools.r8)
+        classpath(libs.gradle.detekt.plugin)
+        classpath(libs.gradle.objectbox.plugin)
+        classpath(libs.gradle.paparazzi.plugin)
+        classpath(libs.gradle.kotlinter)
+        classpath(libs.gradle.dokka.plugin)
     }
 }
 
-allprojects {
-    apply(plugin = "org.jlleitschuh.gradle.ktlint")
-    apply(plugin = "org.jetbrains.dokka")
-}
-
-subprojects {
-    apply(plugin = "org.jetbrains.dokka")
-}
-
 tasks.register<Delete>("clean") {
     delete(rootProject.layout.buildDirectory)
 }

+ 48 - 46
buildSrc/build.gradle.kts

@@ -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
  */
 
 @Suppress("DEPRECATION", "JcenterRepositoryObsolete")
@@ -15,59 +15,61 @@ repositories {
     maven(url = "https://www.jitpack.io")
     maven(url = "https://plugins.gradle.org/m2/")
     maven(url = "https://oss.sonatype.org/content/repositories/public")
+    maven(url = "https://oss.sonatype.org/content/repositories/snapshots")
 }
 
 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")
+//    id("com.github.ben-manes.versions") version "0.41.0"
+//    id("nl.littlerobots.version-catalog-update") version "0.8.4"
 }
 
 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("com.squareup:javapoet:1.13.0")
-//    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")
+    implementation(kotlin("reflect"))
+    implementation(libs.gradle.api)
+    implementation(libs.gradle)
+    implementation(libs.gradle.kotlin.plugin)
+    implementation(libs.gradle.detekt.plugin)
+    implementation(libs.gradle.dokka.plugin)
+    implementation(libs.gradle.kotlinter)
+//    implementation(libs.ktoml.core)
+//    implementation(libs.ktoml.file)
+    implementation(libs.jsoup)
+    implementation(libs.kotlin.stdlib.jdk8)
+    implementation(libs.detekt.formatting)
+    implementation(libs.poet.java)
+    implementation(libs.poet.kotlin)
+    implementation(libs.eclipse.jgit)
+    implementation(libs.okhttp3)
+    implementation(libs.okhttp3.logging.interceptor)
+    implementation(libs.moshi.kotlin)
+    implementation(libs.kotlin.mockito)
+    implementation(libs.junit)
+//    implementation(libs.apk.parser)
+//    implementation(libs.gradle.docker.plugin)
+//    implementation(libs.korim.jvm)
+//    implementation(libs.photofilter)
+//    implementation(libs.dependency.analysis.gradle.plugin)
+//    implementation(libs.google.api.services.androidpublisher)
+//    implementation(libs.ini4j)
+//    dokkaPlugin("org.jetbrains.dokka:android-documentation-plugin:1.9.20")
 }
 
 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("LibPlugin") {
+            id = "LibPlugin"
+            displayName = "LibPlugin"
+            description = "Common library plugin to handle all stuffs needed."
+            implementationClass = "org.mjdev.gradle.plugin.LibPlugin"
         }
-//        register("DependencyUpdatePlugin") {
-//            id = "DependencyUpdatePlugin"
-//            displayName = "DependencyUpdatePlugin"
-//            description = "Dependency update plugin"
-//            implementationClass = "org.mjdev.gradle.plugin.DependencyUpdatePlugin"
-//        }
-//        register("AndroidCoreLibraryPlugin") {
-//            id = "AndroidCoreLibraryPlugin"
-//            implementationClass = "commons.AndroidCoreLibraryPlugin"
-//        }
     }
-}
+}

+ 15 - 0
buildSrc/settings.gradle.kts

@@ -0,0 +1,15 @@
+/*
+ *  Copyright (c) Milan Jurkulák 2024.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */
+
+dependencyResolutionManagement {
+    versionCatalogs {
+        create("libs") {
+            from(files("../gradle/libs.versions.toml"))
+        }
+    }
+}

+ 98 - 0
buildSrc/src/main/kotlin/org/mjdev/gradle/base/BasePlugin.kt

@@ -0,0 +1,98 @@
+/*
+ *  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.akuleshov7.ktoml.Toml
+import io.gitlab.arturbosch.detekt.Detekt
+import org.gradle.api.Plugin
+import org.gradle.api.Project
+import org.gradle.api.Task
+import org.gradle.api.artifacts.VersionCatalogsExtension
+import org.gradle.api.internal.catalog.VersionCatalogView
+import org.gradle.internal.impldep.org.tomlj.Toml
+import org.gradle.kotlin.dsl.extra
+import org.gradle.kotlin.dsl.withType
+import org.jetbrains.dokka.gradle.DokkaTask
+import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
+import java.io.FileReader
+import kotlin.reflect.KClass
+
+@Suppress("HasPlatformType", "MemberVisibilityCanBePrivate")
+abstract class BasePlugin : Plugin<Project> {
+
+    val IMPLEMENTATION = "implementation"
+
+    override fun apply(project: Project) {
+        project.work()
+    }
+
+    abstract fun Project.work()
+
+    inline fun <reified T> Project.extension(name: String? = null): T = run {
+        var cfg = project.extensions.findByType(T::class.java)
+        if (cfg == null && name != null) {
+            project.extensions.add(name, T::class.java)
+        }
+        cfg = project.extensions.findByType(T::class.java)
+        cfg!!
+    }
+
+    inline fun <reified T : Any> Project.extension(): T = runCatching {
+        extensions.getByType(T::class.java)
+    }.onFailure {
+        println("Config missing.")
+    }.getOrThrow()
+
+    fun <T : Plugin<*>> Project.apply(type: KClass<T>): T = plugins.apply(type.java)
+
+    fun Project.apply(id: String) = plugins.apply(id)
+
+    fun Project.kotlinCompileOptions(scoped: KotlinCompile.() -> Unit) {
+        tasks.withType<KotlinCompile>().first().also { t -> scoped(t) }
+    }
+
+    fun Project.detektTask(scoped: Detekt.() -> Unit) {
+        tasks.withType<Detekt>().first().also { t -> scoped(t) }
+    }
+
+    fun Project.dokkaTask(scoped: DokkaTask.() -> Unit) {
+        tasks.withType<DokkaTask>().first().also { t -> scoped(t) }
+    }
+
+    val Project.assembleTasks
+        get() = tasks.filter { t -> t.name.startsWith("assemble") }
+
+    fun Task.runAfterAssembleTask() {
+        project.assembleTasks.forEach { t -> t.finalizedBy(this) }
+    }
+
+    val Project.versions
+        get() = extra["versions"]
+
+    val Project.libraries
+        get() = extra["libraries"]
+
+    val Project.plugins
+        get() = extra["plugins"]
+
+    fun Project.loadVersionCatalog(path: String) {
+        val file = rootDir.resolve(path)
+        println("Parsing: $file")
+        val toml = FileReader(file).use { f ->
+            Toml.parse(f)
+        }
+        val versions = toml.getTable("versions")?.toMap()
+        val libraries = toml.getTable("libraries")
+        val plugins = toml.getTable("libraries")
+        extra["versions"] = versions
+        extra["libraries"] = libraries
+        extra["plugins"] = plugins
+    }
+
+}

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

@@ -0,0 +1,82 @@
+/*
+ *  Copyright (c) Milan Jurkulák 2024.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */
+
+@file:Suppress("DEPRECATION", "unused")
+
+package org.mjdev.gradle.base
+
+import com.android.build.gradle.api.ApplicationVariant
+import org.gradle.api.DefaultTask
+import org.gradle.api.logging.Logging
+import org.gradle.api.tasks.Internal
+import org.gradle.api.tasks.SourceTask
+import org.mjdev.gradle.extensions.applicationId
+import org.mjdev.gradle.extensions.buildDirectory
+import org.mjdev.gradle.extensions.capitalize
+import org.mjdev.gradle.extensions.file
+import org.mjdev.gradle.extensions.packageName
+import java.net.URL
+
+open class BaseTask : DefaultTask() {
+
+    private val log by lazy {
+        Logging.getLogger(this::class.java)
+    }
+
+    @Internal
+    lateinit var variant: ApplicationVariant
+
+    val variantName: String
+        @Internal
+        get() = variant.name.capitalize()
+
+    val javaCompiledClasses: URL
+        @Internal
+        get() = variant
+            .javaCompileProvider
+            .get()
+            .destinationDirectory
+            .asFile
+            .get()
+            .apply {
+                println("> Compiled java classes path : $this")
+            }
+            .toURI()
+            .toURL()
+
+    val kotlinCompiledClasses: URL
+        @Internal
+        get() = project.buildDirectory
+            .file("tmp")
+            .file("kotlin-classes")
+            .file(variant.name)
+            .file(project.applicationId.replace(".","/"))
+            .apply {
+                println("> Compiled kotlin classes path : $this")
+            }
+            .toURI()
+            .toURL()
+
+    val restDependencies
+        @Internal
+        get() = variant
+            .getCompileClasspath(null)
+            .files
+            .map {
+                it.toURI().toURL()
+            }.toTypedArray()
+
+    val kotlinCompileTask
+        @Internal
+        get() = project.tasks
+            .findByName("compile${variant.name.capitalize()}Kotlin") as? SourceTask
+
+
+    fun println(message: String) = log.lifecycle(message)
+
+}

+ 0 - 272
buildSrc/src/main/kotlin/org/mjdev/gradle/dependency/DependencyHandler.kt

@@ -1,272 +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.dependency
-
-import org.gradle.api.artifacts.Dependency
-import org.gradle.api.artifacts.dsl.DependencyHandler
-import kotlin.String
-
-fun DependencyHandler.debugImplementation(dependencyNotation: String): Dependency? =
-    add("debugImplementation", dependencyNotation)
-
-fun DependencyHandler.releaseImplementation(dependencyNotation: String): Dependency? =
-    add("releaseImplementation", dependencyNotation)
-
-fun DependencyHandler.testImplementation(dependencyNotation: String): Dependency? =
-    add("testImplementation", dependencyNotation)
-
-fun DependencyHandler.androidTestImplementation(dependencyNotation: String): Dependency? =
-    add("androidTestImplementation", dependencyNotation)
-
-fun DependencyHandler.androidTestImplementation(dependency: Dependency): Dependency? =
-    add("androidTestImplementation", dependency)
-
-fun DependencyHandler.testAnnotationProcessor(dependencyNotation: String): Dependency? =
-    add("testAnnotationProcessor", dependencyNotation)
-
-fun DependencyHandler.androidTestAnnotationProcessor(dependencyNotation: String): Dependency? =
-    add("androidTestAnnotationProcessor", dependencyNotation)
-
-fun DependencyHandler.implementation(dependencyNotation: String): Dependency? =
-    add("implementation", dependencyNotation)
-
-fun DependencyHandler.implementation(dependency: Dependency): Dependency? =
-    add("implementation", dependency)
-
-fun DependencyHandler.kapt(dependencyNotation: String): Dependency? =
-    add("kapt", dependencyNotation)
-
-fun DependencyHandler.ksp(dependencyNotation: String): Dependency? =
-    add("ksp", dependencyNotation)
-
-fun DependencyHandler.dokkaPlugin(dependencyNotation: String): Dependency? =
-    add("dokkaPlugin", dependencyNotation)
-
-fun DependencyHandler.mockDependencies() = arrayOf(
-    implementation("io.mockk:mockk:1.9")
-)
-
-fun DependencyHandler.okHttpDependencies() = arrayOf(
-    implementation(platform("com.squareup.okhttp3:okhttp-bom:5.0.0-alpha.12")),
-    implementation("com.squareup.okhttp3:okhttp"),
-    implementation("com.squareup.okhttp3:logging-interceptor:5.0.0-alpha.12"),
-)
-
-fun DependencyHandler.retrofitDependencies() = arrayOf(
-    implementation("com.squareup.retrofit2:retrofit:2.9.0"),
-    implementation("com.squareup.retrofit2:converter-moshi:2.9.0"),
-    implementation("com.jakewharton.retrofit:retrofit2-kotlin-coroutines-adapter:0.9.2"),
-)
-
-fun DependencyHandler.moshiDependencies() = arrayOf(
-    implementation("com.squareup.moshi:moshi:1.15.0"),
-    implementation("com.squareup.retrofit2:converter-moshi:2.9.0"),
-    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3"),
-    ksp("com.squareup.moshi:moshi-kotlin-codegen:1.15.0"),
-)
-
-fun DependencyHandler.exoPlayerDependencies() = arrayOf(
-    implementation("androidx.media3:media3-exoplayer:1.2.0"),
-    implementation("androidx.media3:media3-exoplayer-dash:1.2.0"),
-    implementation("androidx.media3:media3-exoplayer-hls:1.2.0"),
-    implementation("androidx.media3:media3-exoplayer-rtsp:1.2.0"),
-    implementation("androidx.media3:media3-datasource-cronet:1.2.0"),
-    implementation("androidx.media3:media3-datasource-okhttp:1.2.0"),
-    implementation("androidx.media3:media3-datasource-rtmp:1.2.0"),
-    implementation("androidx.media3:media3-ui:1.2.0"),
-    implementation("androidx.media3:media3-session:1.2.0"),
-    implementation("androidx.media3:media3-extractor:1.2.0"),
-    implementation("androidx.media3:media3-cast:1.2.0"),
-    implementation("androidx.media3:media3-exoplayer-workmanager:1.2.0"),
-    implementation("androidx.media3:media3-transformer:1.2.0"),
-    implementation("androidx.media3:media3-database:1.2.0"),
-    implementation("androidx.media3:media3-decoder:1.2.0"),
-    implementation("androidx.media3:media3-datasource:1.2.0"),
-    implementation("androidx.media3:media3-common:1.2.0"),
-    implementation("androidx.media3:media3-exoplayer-ima:1.1.0"),
-    implementation("androidx.media3:media3-ui-leanback:1.1.0"),
-)
-
-fun DependencyHandler.composeDependencies() = arrayOf(
-    implementation(platform("androidx.compose:compose-bom:2023.10.01")),
-    implementation("androidx.compose.ui:ui-tooling"),
-    implementation("androidx.activity:activity-compose:1.8.2"),
-    // more icons
-    implementation("androidx.compose.material:material-icons-extended:1.5.4"),
-    // tv compose
-    implementation("androidx.tv:tv-foundation:1.0.0-alpha10"),
-    implementation("androidx.tv:tv-material:1.0.0-alpha10"),
-    // view model
-    implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.6.2"),
-    // navigation
-    implementation("androidx.navigation:navigation-compose:2.7.6"),
-    // todo remove, dynamic background & colors
-    implementation("androidx.compose.material3:material3:1.1.2"),
-    // previews
-    debugImplementation("androidx.customview:customview-poolingcontainer:1.0.0"),
-    // foundation
-    implementation("androidx.compose.foundation:foundation:1.1.1"),
-)
-
-fun DependencyHandler.baseDependencies() = arrayOf(
-    // startups
-//    implementation("androidx.startup:startup-runtime:1.1.1"),
-    // dokka
-    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"),
-    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3"),
-    // reflect / todo : remove
-    implementation("org.jetbrains.kotlin:kotlin-reflect:1.9.22"),
-    // window manager
-    implementation("androidx.window:window:1.2.0"),
-    // fix duplicates
-    implementation("androidx.work:work-runtime-ktx:2.9.0"),
-)
-
-fun DependencyHandler.sandwichDependencies() = arrayOf(
-    implementation("com.github.skydoves:sandwich:2.0.5"),
-    implementation("com.github.skydoves:sandwich-retrofit:2.0.5"),
-)
-
-fun DependencyHandler.glideDependencies() = arrayOf(
-    // glide
-    implementation("com.github.bumptech.glide:okhttp3-integration:4.16.0"),
-    implementation("com.github.bumptech.glide:compose:1.0.0-beta01"),
-    // landscapist
-    implementation("com.github.skydoves:landscapist-glide:2.2.13"),
-    implementation("com.github.skydoves:landscapist-transformation:2.2.13"),
-    implementation("com.github.skydoves:landscapist-palette:2.2.13"),
-    implementation("com.github.skydoves:landscapist-placeholder:2.2.13"),
-    // glide annotations
-    ksp("com.github.bumptech.glide:ksp:4.16.0"),
-)
-
-fun DependencyHandler.daggerDependencies() = arrayOf(
-    // dagger
-    implementation("com.google.dagger:dagger:2.50"),
-    // dagger android
-    implementation("com.google.dagger:dagger-android:2.50"),
-    implementation("com.google.dagger:dagger-android-support:2.50"),
-    // dagger hilt
-    implementation("com.google.dagger:hilt-android:2.50"),
-    implementation("androidx.hilt:hilt-navigation-compose:1.1.0"),
-    // dagger annotations
-    kapt("com.google.dagger:dagger-compiler:2.50"),
-    kapt("com.google.dagger:dagger-android-processor:2.50"),
-    kapt("com.google.dagger:hilt-compiler:2.50"),
-)
-
-fun DependencyHandler.timberDependencies() = arrayOf(
-    implementation("com.jakewharton.timber:timber:5.0.1"),
-)
-
-fun DependencyHandler.oauthDependencies() = arrayOf(
-    implementation("com.auth0.android:auth0:2.10.2"),
-    implementation("com.auth0.android:jwtdecode:2.0.2"),
-)
-
-fun DependencyHandler.testDependencies() = arrayOf(
-    testImplementation("junit:junit:4.13.2"),
-    androidTestImplementation("androidx.test.ext:junit:1.1.5"),
-    androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1"),
-    // anr
-    implementation("com.github.anrwatchdog:anrwatchdog:1.4.0"),
-)
-
-fun DependencyHandler.encryptDependencies() = arrayOf(
-    implementation("com.scottyab:aescrypt:0.0.1"),
-)
-
-fun DependencyHandler.permissionsDependencies() = arrayOf(
-// permission
-    implementation("com.google.accompanist:accompanist-permissions:0.33.2-alpha"),
-)
-
-fun DependencyHandler.widgetDependencies() = arrayOf(
-    implementation("androidx.glance:glance-appwidget:1.0.0"),
-    implementation("androidx.glance:glance-material:1.0.0"),
-    implementation("androidx.glance:glance-material3:1.0.0"),
-)
-
-fun DependencyHandler.stripeDependencies() = arrayOf(
-    // stripe payments
-    implementation("com.stripe:stripe-android:20.33.0"),
-    implementation("com.stripe:stripecardscan:20.33.0"),
-    implementation("com.stripe:financial-connections:20.33.0"),
-)
-
-fun DependencyHandler.anotherDependencies() = arrayOf(
-    // zxing
-    implementation("com.google.zxing:core:3.5.2"),
-    // svg
-    implementation("com.caverock:androidsvg-aar:1.4"),
-    // pallette
-    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"),
-    // 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("org.htmlunit:htmlunit3-android:3.7.0"),
-//    implementation("org.htmlunit:htmlunit:3.9.0"),
-    // flip card
-    implementation("com.wajahatkarim:flippable:1.5.4"),
-    // ui controller
-//    implementation("com.google.accompanist:accompanist-systemuicontroller:0.23.1"),
-    // insets
-//    implementation("com.google.accompanist:accompanist-insets:0.23.1"),
-    // indicators
-//    implementation("com.google.accompanist:accompanist-pager-indicators:0.23.1"),
-    // settings
-//    implementation("com.github.alorma:compose-settings-ui-m3:1.3.0")
-    // yt
-    // ...
-    // a.i.
-//    implementation("com.google.ai.client.generativeai:generativeai:0.1.1"),
-)
-
-fun DependencyHandler.mjdevTvLib() = arrayOf(
-    implementation(project(mapOf("path" to ":tvlib")))
-)
-
-//fun DependencyHandler.imageIODependencies() = arrayOf(
-//    implementation("com.twelvemonkeys.imageio:imageio-core:3.9.4"),
-//    implementation("com.twelvemonkeys.imageio:imageio-webp:3.9.4"),
-////    implementation("com.twelvemonkeys.imageio:imageio-png:3.9.4"),
-//    implementation("com.twelvemonkeys.imageio:imageio-bmp:3.9.4"),
-//    implementation("com.twelvemonkeys.imageio:imageio-jpeg:3.9.4"),
-//    implementation("com.twelvemonkeys.imageio:imageio-tiff:3.9.4"),
-//    implementation("com.twelvemonkeys.imageio:imageio-batik:3.9.4"),
-//    implementation("com.twelvemonkeys.imageio:imageio-metadata:3.9.4"),
-//    implementation("com.twelvemonkeys.imageio:imageio-psd:3.9.4"),
-//    implementation("com.twelvemonkeys.imageio:imageio-icns:3.9.4"),
-//    implementation("com.twelvemonkeys.imageio:imageio-pnm:3.9.4"),
-//    implementation("com.twelvemonkeys.imageio:imageio-sgi:3.9.4"),
-//    implementation("com.twelvemonkeys.imageio:imageio-pict:3.9.4"),
-//    implementation("com.twelvemonkeys.imageio:imageio-tga:3.9.4"),
-//    implementation("com.twelvemonkeys.imageio:imageio-pcx:3.9.4"),
-//    implementation("com.twelvemonkeys.imageio:imageio-iff:3.9.4"),
-//    implementation("com.twelvemonkeys.imageio:imageio-pdf:3.9.4"),
-//    implementation("com.twelvemonkeys.imageio:imageio-pnm:3.9.4"),
-//    implementation("com.twelvemonkeys.imageio:imageio-hdr:3.9.4"),
-//    implementation("com.twelvemonkeys.imageio:imageio-clippath:3.9.4"),
-//    implementation("com.twelvemonkeys.imageio:imageio-thumbsdb:3.9.4"),
-//    implementation("com.twelvemonkeys.imageio:imageio-xwd:3.9.4"),
-//)

+ 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
+    }
+}

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

@@ -0,0 +1,117 @@
+/*
+ *  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) }
+}
+
+fun File.allFiles(ext: String): List<File> = mutableListOf<File>().also { list ->
+    listFiles()?.forEach { f ->
+        when {
+            f.extension == ext -> list.add(f)
+            f.isDirectory -> list.addAll(f.allFiles(ext))
+        }
+    }
+}
+
+val File.asTestFile
+    get() = File(nameWithoutExtension + "Test" + "." + extension)

+ 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])
+    }
+}

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

+ 0 - 46
buildSrc/src/main/kotlin/org/mjdev/gradle/extensions/PluginDependenciesSpecScope.kt

@@ -1,46 +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.extensions
-
-import org.gradle.kotlin.dsl.PluginDependenciesSpecScope
-import org.gradle.kotlin.dsl.kotlin
-import org.gradle.plugin.use.PluginDependenciesSpec
-import org.gradle.plugin.use.PluginDependencySpec
-
-fun PluginDependenciesSpec.ksp(): PluginDependencySpec =
-    id("com.google.devtools.ksp")
-
-fun PluginDependenciesSpecScope.hilt(): PluginDependencySpec =
-    id("dagger.hilt.android.plugin")
-
-fun PluginDependenciesSpecScope.androidHilt(): PluginDependencySpec =
-    id("com.google.dagger.hilt.android")
-
-fun PluginDependenciesSpecScope.objectBox(): PluginDependencySpec =
-    id("io.objectbox")
-
-fun PluginDependenciesSpecScope.kotlinAndroid(): PluginDependencySpec =
-    kotlin("android")
-
-fun PluginDependenciesSpecScope.kotlinKapt(): PluginDependencySpec =
-    kotlin("kapt")
-
-fun PluginDependenciesSpecScope.kotlinParcelize(): PluginDependencySpec =
-    id("kotlin-parcelize")
-
-fun PluginDependenciesSpecScope.androidLibrary(): PluginDependencySpec =
-    id("com.android.library")
-
-fun PluginDependenciesSpecScope.androidApplication(): PluginDependencySpec =
-    id("com.android.application")
-
-fun PluginDependenciesSpecScope.mainAppPlugin(): PluginDependencySpec =
-    id("MainAppPlugin")

+ 183 - 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", "RedundantRunCatching")
 
 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.ApplicationVariant
+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.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,189 @@ 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.isAndroid: Boolean
+    get() = extensions.findByType<ApplicationExtension>() != null
 
-val Project.android
-    get() = extensions.getByType(ApplicationExtension::class.java)
+val Project.packageName: String
+    get() = applicationId
 
-val Project.androidComponents
+val Project.applicationId: String
+    get() = if (isAndroid) android.defaultConfig.applicationId ?: "" else ""
+
+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<ApplicationVariant>
+    get() = androidExtension.applicationVariants.toList()
+
+val Project.unitTestDir: File
+    get() = file("src").file("test").file("java")
+
+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 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()
+}
+
+//fun Project.androidComponents(
+//    block: AndroidComponentsExtension<*, *, *>.() -> Unit
+//) {
+//    block.invoke(androidComponents)
+//}

+ 31 - 11
buildSrc/src/main/kotlin/org/mjdev/gradle/extensions/SigningConfigs.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
@@ -10,18 +10,26 @@
 
 package org.mjdev.gradle.extensions
 
-import com.android.build.gradle.BaseExtension
+import com.android.build.api.dsl.ApplicationExtension
 import org.gradle.api.Project
 import java.io.FileInputStream
 import java.util.*
 import kotlin.String
 
-fun BaseExtension.setSigningConfigs(
-    project: Project
+class SigningsScope(
+    var debugKeyFile: String = "",
+    var releaseKeyFile: String = ""
+)
+
+fun ApplicationExtension.setSigningConfigs(
+    project: Project,
+    scope: SigningsScope = SigningsScope(),
+    config: SigningsScope.() -> Unit = {}
 ) = signingConfigs {
-    create("signingConfigRelease") {
+    config.invoke(scope)
+    create("release") {
         val rootProject = project.rootProject
-        val keystorePropertiesFile = rootProject.file("signing/release.signing.properties")
+        val keystorePropertiesFile = rootProject.file(scope.releaseKeyFile)
         if (!keystorePropertiesFile.exists()) {
             System.err.println("📜 Missing release.signing.properties file for release signing")
         } else {
@@ -38,11 +46,23 @@ fun BaseExtension.setSigningConfigs(
             }
         }
     }
-
     getByName("debug") {
-        storeFile = project.rootProject.file("signing/debug.keystore")
-        keyAlias = "androiddebugkey"
-        keyPassword = "android"
-        storePassword = "android"
+        val rootProject = project.rootProject
+        val keystorePropertiesFile = rootProject.file(scope.debugKeyFile)
+        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")
+            }
+        }
     }
 }

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

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

@@ -0,0 +1,267 @@
+/*
+ *  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.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.GradleException
+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 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)
+//    mustRunAfter(task)
+//}
+
+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.forEach { task ->
+    if (task is T) {
+        task.finalizedBy(this)
+    }
+}
+
+inline fun <reified T : Task> Task.shouldRunAfter() = tasks.forEach { task ->
+    if (task is T) {
+        this.shouldRunAfter(task)
+    }
+}
+
+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()
+        }
+    }
+
+

+ 46 - 6
buildSrc/src/main/kotlin/org/mjdev/gradle/extensions/VariantDimension.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
@@ -10,10 +10,10 @@
 
 package org.mjdev.gradle.extensions
 
-import com.android.build.api.dsl.VariantDimension
+import com.android.build.api.dsl.ApplicationVariantDimension
 import kotlin.String
 
-fun VariantDimension.buildConfigBoolean(
+fun ApplicationVariantDimension.buildConfigBoolean(
     vararg fields: Pair<String, Boolean>
 ) {
     fields.forEach { pair ->
@@ -21,7 +21,7 @@ fun VariantDimension.buildConfigBoolean(
     }
 }
 
-fun VariantDimension.buildConfigString(
+fun ApplicationVariantDimension.buildConfigString(
     vararg fields: Pair<String, String>
 ) {
     fields.forEach { pair ->
@@ -29,7 +29,15 @@ fun VariantDimension.buildConfigString(
     }
 }
 
-fun VariantDimension.stringRes(
+fun ApplicationVariantDimension.buildConfigStringWithPackageName(
+    vararg fields: Pair<String, String>
+) {
+    fields.forEach { pair ->
+        buildConfigField("String", pair.first, "APPLICATION_ID + \"${pair.second}\"")
+    }
+}
+
+fun ApplicationVariantDimension.stringRes(
     vararg fields: Pair<String, String>
 ) {
     fields.forEach { pair ->
@@ -37,7 +45,39 @@ fun VariantDimension.stringRes(
     }
 }
 
-fun VariantDimension.manifestPlaceholders(
+fun ApplicationVariantDimension.stringRes(
+    name: String,
+    value: String
+) {
+    resValue("string", name, value)
+}
+
+fun ApplicationVariantDimension.stringResWithPackageName(
+    vararg fields: Pair<String, String>
+) {
+    fields.forEach { pair ->
+        resValue("string", pair.first, "\${applicationId}" + pair.second)
+    }
+}
+
+fun ApplicationVariantDimension.addSyncProviderAuthString(
+    name: String = "sync_auth",
+    suffix: String = ".sync"
+) {
+    buildConfigStringWithPackageName(name.uppercase() to suffix)
+    stringResWithPackageName(name to suffix)
+}
+
+//fun ApplicationVariantDimension.createSuffixedStringRes(
+//    name: String,
+//    base: String
+//) {
+//    val suffix = if(applicationIdSuffix.isNullOrEmpty()) ""
+//    else ( " - "  + applicationIdSuffix.suffixToString())
+//    resValue("string", name, base + suffix)
+//}
+
+fun ApplicationVariantDimension.manifestPlaceholders(
     vararg fields: Pair<String, Any>
 ) {
     manifestPlaceholders.apply {

+ 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 {
-
-}

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

@@ -0,0 +1,248 @@
+/*
+ *  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.api.dsl.ApplicationExtension
+import io.gitlab.arturbosch.detekt.DetektPlugin
+import io.gitlab.arturbosch.detekt.extensions.DetektExtension
+import org.gradle.api.JavaVersion
+import org.gradle.api.Project
+import org.gradle.api.artifacts.VersionCatalogsExtension
+import org.gradle.kotlin.dsl.apply
+import org.gradle.kotlin.dsl.configure
+import org.mjdev.gradle.extensions.addSyncProviderAuthString
+import org.mjdev.gradle.extensions.buildConfigString
+import org.mjdev.gradle.extensions.manifestPlaceholders
+import org.mjdev.gradle.extensions.setSigningConfigs
+import org.mjdev.gradle.extensions.stringRes
+import org.mjdev.gradle.extensions.versionCode
+import org.mjdev.gradle.extensions.versionName
+import org.gradle.kotlin.dsl.dependencies
+import org.jetbrains.dokka.Platform
+import org.jmailen.gradle.kotlinter.KotlinterExtension
+import org.jmailen.gradle.kotlinter.KotlinterPlugin
+import org.mjdev.gradle.base.BasePlugin
+import org.mjdev.gradle.plugin.config.AppConfig
+
+@Suppress("UnstableApiUsage")
+class AppPlugin : BasePlugin() {
+    private val projectNamespace = "org.mjdev.tvapp"
+    private val projectCompileSdk = 34
+    private val projectMinSdk = 21
+    private val projectJavaVersion = JavaVersion.VERSION_17
+    private val projectKotlinCompilerVersion = "1.5.8"
+    private val projectExcludes = listOf(
+        "META-INF/",
+        "/META-INF/{AL2.0,LGPL2.1}",
+        "/META-INF/DEPENDENCIES",
+        "/mozilla/public-suffix-list.txt",
+        "okhttp3/",
+        "kotlin/",
+        "org/",
+        ".properties",
+        ".bin",
+    )
+    private val projectProguardFile = "proguard-android-optimize.txt"
+    private val projectProguardRulesFile = "proguard-rules.pro"
+    private val projectJacocoVersion = "0.8.8"
+    private val projectLibVersionsFile = "gradle/libs.versions.toml"
+    private val configFieldName = "appConfig"
+
+    override fun Project.work() {
+        loadVersionCatalog(projectLibVersionsFile)
+        extension<AppConfig>(configFieldName)
+        apply(plugin = "version-catalog")
+        apply(plugin = "kotlin-android")
+        apply(plugin = "com.android.application")
+        apply(plugin = "kotlin-kapt")
+        apply(plugin = "kotlin-parcelize")
+        apply(plugin = "com.google.devtools.ksp")
+        apply(plugin = "com.google.dagger.hilt.android")
+        apply(plugin = "dagger.hilt.android.plugin")
+        apply(plugin = "io.objectbox")
+        apply(DetektPlugin::class)
+        apply(KotlinterPlugin::class)
+        apply("org.jetbrains.dokka")
+        configure<ApplicationExtension> {
+            namespace = projectNamespace
+            compileSdk = projectCompileSdk
+            setSigningConfigs(project) {
+                debugKeyFile = "config/signing.prop"
+                releaseKeyFile = "config/signing.prop"
+            }
+            buildFeatures {
+                compose = true
+                buildConfig = true
+            }
+            compileOptions {
+                sourceCompatibility = projectJavaVersion
+                targetCompatibility = projectJavaVersion
+            }
+            composeOptions {
+                kotlinCompilerExtensionVersion = projectKotlinCompilerVersion
+            }
+            defaultConfig {
+                applicationId = projectNamespace
+                minSdk = projectMinSdk
+                targetSdk = projectCompileSdk
+                versionCode = project.versionCode
+                versionName = project.versionName
+                multiDexEnabled = true
+                buildConfigString(
+                    "IPTV_API_URL" to "https://iptv-org.github.io/api/",
+                    "GITHUB_USER" to "mimoccc",
+                    "GITHUB_REPOSITORY" to "tvapp"
+                )
+                manifestPlaceholders(
+                    "auth0Domain" to "@string/com_auth0_domain",
+                    "auth0Scheme" to "demo"
+                )
+                testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+            }
+            packaging {
+                resources {
+                    excludes.addAll(projectExcludes)
+                }
+            }
+            buildTypes {
+                debug {
+                    applicationIdSuffix = ".$name"
+                    isDebuggable = true
+                    isJniDebuggable = true
+                    isMinifyEnabled = false
+                    isShrinkResources = false
+                    isCrunchPngs = false
+//                  isEmbedMicroApp = true
+//                  isPseudoLocalesEnabled = true
+//                  enableUnitTestCoverage = true
+//                  enableAndroidTestCoverage = true
+//                  isRenderscriptDebuggable = true
+//                  isZipAlignEnabled = false
+                    signingConfig = signingConfigs.getAt(name)
+                    stringRes("app_name", "TVApp-Debug")
+                    addSyncProviderAuthString("sync_auth", ".sync")
+                    proguardFiles(
+                        getDefaultProguardFile(projectProguardFile),
+                        projectProguardRulesFile
+                    )
+                }
+                release {
+                    applicationIdSuffix = ".$name"
+                    isDebuggable = false
+                    isJniDebuggable = false
+                    isMinifyEnabled = true
+                    isShrinkResources = true
+                    isCrunchPngs = true
+//                  isEmbedMicroApp = true
+//                  isPseudoLocalesEnabled = false
+//                  enableUnitTestCoverage = false
+//                  enableAndroidTestCoverage = false
+//                  isRenderscriptDebuggable = false
+//                  isZipAlignEnabled = true
+                    signingConfig = signingConfigs.getAt(name)
+                    stringRes("app_name", "TVApp")
+                    addSyncProviderAuthString("sync_auth", ".sync")
+                    proguardFiles(
+                        getDefaultProguardFile(projectProguardFile),
+                        projectProguardRulesFile
+                    )
+                }
+            }
+//            kotlinOptions {
+//                jvmTarget = JavaVersion.VERSION_17.toString()
+//            }
+            sourceSets {
+                getByName("main") { jniLibs.srcDirs() }
+            }
+            lint {
+                checkReleaseBuilds = false
+                checkAllWarnings = true
+                showAll = true
+                explainIssues = true
+                abortOnError = true
+                warningsAsErrors = true
+                disable += "UnusedIds"
+            }
+//            kapt {
+//                correctErrorTypes = true
+//            }
+//            hilt {
+//                enableAggregatingTask = true
+//            }
+            testOptions {
+                unitTests.isReturnDefaultValues = true
+            }
+            testCoverage {
+                jacocoVersion = projectJacocoVersion
+            }
+        }
+        // app
+        afterEvaluate {
+            val appConfig = project.extension<AppConfig>()
+            kotlinCompileOptions {
+                kotlinOptions {
+                    freeCompilerArgs += "-Xjsr305=strict"
+                    jvmTarget = projectJavaVersion.toString()
+                }
+            }
+            detektTask {
+                if (appConfig.autoCorrectCode) runAfterAssembleTask()
+            }
+            dokkaTask {
+                outputDirectory.set(project.rootDir.resolve(appConfig.documentationDir))
+                moduleName.set(project.name)
+                suppressObviousFunctions.set(false)
+                dokkaSourceSets.configureEach {
+                    offlineMode.set(false)
+                    includeNonPublic.set(true)
+                    skipDeprecated.set(false)
+                    reportUndocumented.set(true)
+                    skipEmptyPackages.set(false)
+                    platform.set(Platform.jvm)
+                    jdkVersion.set(17)
+                    noStdlibLink.set(false)
+                    noJdkLink.set(false)
+                    noAndroidSdkLink.set(false)
+                }
+                if (appConfig.createDocumentation) runAfterAssembleTask()
+            }
+            configure<DetektExtension> {
+                @Suppress("DEPRECATION")
+                config = files(project.rootDir.resolve(appConfig.detectConfigFile))
+            }
+            configure<KotlinterExtension> {
+                ignoreFailures = false
+                reporters = arrayOf("checkstyle", "plain")
+            }
+//            configurations.getByName("releaseRuntimeClasspath") {
+//                resolutionStrategy.activateDependencyLocking()
+//            }
+//            configurations.getByName("debugRuntimeClasspath") {
+//                resolutionStrategy.activateDependencyLocking()
+//            }
+//            applicationVariants.all {
+//                outputs.map {
+//                    it as BaseVariantOutputImpl
+//                }.forEach { output ->
+//                    val outputFileName = "$applicationId-$versionName.apk"
+//                    output.outputFileName = outputFileName
+//                }
+//            }
+        }
+        dependencyLocking {
+//            lockMode.set(LockMode.STRICT)
+        }
+        dependencies {
+//            add(IMPLEMENTATION, libs.androidx.compose.ui.tooling)
+//            add(IMPLEMENTATION, Dependencies.AndroidX.ANNOTATION)
+//            add(IMPLEMENTATION, Dependencies.AndroidX.APPCOMPAT)
+//            add(IMPLEMENTATION, Dependencies.Kotlin.STD_LIB)
+        }
+    }
+}

+ 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()
-    }
-}

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

@@ -0,0 +1,294 @@
+/*
+ *  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.api.dsl.LibraryExtension
+import io.gitlab.arturbosch.detekt.DetektPlugin
+import io.gitlab.arturbosch.detekt.extensions.DetektExtension
+import org.gradle.api.JavaVersion
+import org.gradle.api.Project
+import org.gradle.kotlin.dsl.apply
+import org.gradle.kotlin.dsl.configure
+import org.gradle.kotlin.dsl.dependencies
+import org.jetbrains.dokka.Platform
+import org.jmailen.gradle.kotlinter.KotlinterExtension
+import org.jmailen.gradle.kotlinter.KotlinterPlugin
+import org.mjdev.gradle.base.BasePlugin
+import org.mjdev.gradle.plugin.config.LibConfig
+
+@Suppress("UnstableApiUsage")
+class LibPlugin : BasePlugin() {
+    private val projectNamespace = "org.mjdev.tvlib"
+    private val projectCompileSdk = 34
+    private val projectMinSdk = 21
+    private val projectJavaVersion = JavaVersion.VERSION_17
+    private val projectKotlinCompilerVersion = "1.5.8"
+    private val projectExcludes = listOf(
+        "META-INF/",
+        "/META-INF/{AL2.0,LGPL2.1}",
+        "/META-INF/DEPENDENCIES",
+        "/mozilla/public-suffix-list.txt",
+        "okhttp3/",
+        "kotlin/",
+        "org/",
+        ".properties",
+        ".bin",
+    )
+    private val projectProguardFile = "proguard-android-optimize.txt"
+    private val projectProguardRulesFile = "proguard-rules.pro"
+    private val projectJacocoVersion = "0.8.8"
+    private val projectLibVersionsFile = "gradle/libs.versions.toml"
+    private val configFieldName = "libConfig"
+
+    override fun Project.work() {
+//        loadVersionCatalog(projectLibVersionsFile)
+        extension<LibConfig>(configFieldName)
+        apply(plugin ="version-catalog")
+        apply(plugin = "kotlin-android")
+        apply(plugin = "com.android.library")
+        apply(plugin = "kotlin-kapt")
+        apply(plugin = "kotlin-parcelize")
+        apply(plugin = "com.google.devtools.ksp")
+        apply(plugin = "com.google.dagger.hilt.android")
+        apply(plugin = "dagger.hilt.android.plugin")
+        apply(plugin = "io.objectbox")
+        apply(DetektPlugin::class)
+        apply(KotlinterPlugin::class)
+        apply("org.jetbrains.dokka")
+        configure<LibraryExtension> {
+            namespace = projectNamespace
+            compileSdk = projectCompileSdk
+            compileOptions {
+                sourceCompatibility = projectJavaVersion
+                targetCompatibility = projectJavaVersion
+            }
+            defaultConfig {
+                minSdk = projectMinSdk
+                testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+            }
+            buildFeatures {
+                compose = true
+                buildConfig = true
+            }
+            composeOptions {
+                kotlinCompilerExtensionVersion = projectKotlinCompilerVersion
+            }
+            packaging {
+                resources {
+                    excludes.addAll(projectExcludes)
+                }
+            }
+            buildTypes {
+                debug {
+                    isMinifyEnabled = false
+                    proguardFiles(
+                        getDefaultProguardFile(projectProguardFile),
+                        projectProguardRulesFile
+                    )
+                }
+                release {
+                    isMinifyEnabled = true
+                    proguardFiles(
+                        getDefaultProguardFile(projectProguardFile),
+                        projectProguardRulesFile
+                    )
+                }
+            }
+            sourceSets {
+                getByName("main") { jniLibs.srcDirs() }
+            }
+            lint {
+                checkAllWarnings = true
+                showAll = true
+                explainIssues = true
+                abortOnError = true
+                warningsAsErrors = true
+                disable += "UnusedIds"
+            }
+            testOptions {
+                unitTests.isReturnDefaultValues = true
+            }
+            testCoverage {
+                jacocoVersion = projectJacocoVersion
+            }
+        }
+        afterEvaluate {
+            val libConfig = project.extension<LibConfig>()
+            kotlinCompileOptions{
+                kotlinOptions {
+                    freeCompilerArgs += "-Xjsr305=strict"
+                    jvmTarget = projectJavaVersion.toString()
+                }
+            }
+            detektTask {
+                if (libConfig.autoCorrectCode) runAfterAssembleTask()
+            }
+            dokkaTask {
+                outputDirectory.set(
+                    project.rootDir.resolve(libConfig.documentationDir)
+                )
+                moduleName.set(project.name)
+                suppressObviousFunctions.set(false)
+                dokkaSourceSets.configureEach {
+                    offlineMode.set(false)
+                    includeNonPublic.set(true)
+                    skipDeprecated.set(false)
+                    reportUndocumented.set(true)
+                    skipEmptyPackages.set(false)
+                    platform.set(Platform.jvm)
+                    jdkVersion.set(17)
+                    noStdlibLink.set(false)
+                    noJdkLink.set(false)
+                    noAndroidSdkLink.set(false)
+                }
+                if (libConfig.createDocumentation) runAfterAssembleTask()
+            }
+            configure<DetektExtension> {
+                @Suppress("DEPRECATION")
+                config = files(project.rootDir.resolve(libConfig.detectConfigFile))
+            }
+            configure<KotlinterExtension> {
+                ignoreFailures = false
+                reporters = arrayOf("checkstyle", "plain")
+            }
+//            configurations.getByName("releaseRuntimeClasspath") {
+//                resolutionStrategy.activateDependencyLocking()
+//            }
+//            configurations.getByName("debugRuntimeClasspath") {
+//                resolutionStrategy.activateDependencyLocking()
+//            }
+        }
+        dependencyLocking {
+//            lockMode.set(LockMode.STRICT)
+        }
+        dependencies {
+//            add(IMPLEMENTATION, Dependencies.AndroidX.ANNOTATION)
+//            add(IMPLEMENTATION, Dependencies.AndroidX.APPCOMPAT)
+//            add(IMPLEMENTATION, Dependencies.Kotlin.STD_LIB)
+//            // core
+//            implementation(libs.androidx.ktx)
+//            implementation(libs.kotlinx.coroutines.android)
+//            implementation(libs.kotlinx.coroutines.core)
+//            // reflect / todo : remove
+//            implementation(libs.kotlin.reflect)
+//            // window manager
+//            implementation(libs.androidx.window)
+//            // fix duplicates
+//            implementation(libs.androidx.work.runtime.ktx)
+//            // compose
+//            implementation(platform(libs.androidx.compose.bom))
+//            implementation(libs.androidx.compose.ui.tooling)
+//            implementation(libs.androidx.compose.activity)
+//            // more icons
+//            implementation(libs.androidx.material.icons.extended)
+//            // tv compose
+//            implementation(libs.androidx.tv.foundation)
+//            implementation(libs.androidx.tv.material)
+//            // view model
+//            implementation(libs.androidx.compose.lifecycle.viewmodel)
+//            // navigation
+//            implementation(libs.androidx.compose.navigation)
+//            // todo remove, dynamic background & colors
+//            implementation(libs.androidx.material3)
+//            // previews
+//            debugImplementation(libs.androidx.customview.poolingcontainer)
+//            // foundation
+//            implementation(libs.androidx.foundation)
+//            // dagger
+//            implementation(libs.dagger)
+//            // dagger android
+//            implementation(libs.dagger.android)
+//            implementation(libs.dagger.android.support)
+//            // dagger hilt
+//            implementation(libs.dagger.hilt.android)
+//            implementation(libs.androidx.compose.hilt.navigation)
+//            kapt(libs.dagger.compiler)
+//            kapt(libs.dagger.android.processor)
+//            kapt(libs.dagger.hilt.compiler)
+//            // moshi
+//            implementation(libs.moshi)
+//            implementation(libs.moshi.retrofit.converter)
+//            ksp(libs.moshi.kotlin.codegen)
+//            // okhttp
+//            implementation(platform(libs.okhttp3.bom))
+//            implementation(libs.okhttp3)
+//            implementation(libs.okhttp3.logging.interceptor)
+//            // timber
+//            implementation(libs.timber)
+//            // retrofit
+//            implementation(libs.retrofit)
+//            implementation(libs.retrofit2.kotlin.coroutines.adapter)
+//            // sandwich
+//            implementation(libs.sandwich)
+//            implementation(libs.sandwich.retrofit)
+//            // glide
+//            implementation(libs.glide.okhttp3.integration)
+//            implementation(libs.glide.compose)
+//            ksp(libs.glide.ksp)
+//            // landscapist
+//            implementation(libs.landscapist.glide)
+//            implementation(libs.landscapist.transformation)
+//            implementation(libs.landscapist.palette)
+//            implementation(libs.landscapist.placeholder)
+//            // exif
+//            implementation(libs.androidx.exifinterface)
+//            // exoPlayer
+//            implementation(libs.androidx.media3.exoplayer)
+//            implementation(libs.androidx.media3.exoplayer.dash)
+//            implementation(libs.androidx.media3.exoplayer.hls)
+//            implementation(libs.androidx.media3.exoplayer.rtsp)
+//            implementation(libs.androidx.media3.datasource.cronet)
+//            implementation(libs.androidx.media3.datasource.okhttp)
+//            implementation(libs.androidx.media3.datasource.rtmp)
+//            implementation(libs.androidx.media3.ui)
+//            implementation(libs.androidx.media3.session)
+//            implementation(libs.androidx.media3.extractor)
+//            implementation(libs.androidx.media3.cast)
+//            implementation(libs.androidx.media3.exoplayer.workmanager)
+//            implementation(libs.androidx.media3.transformer)
+//            implementation(libs.androidx.media3.database)
+//            implementation(libs.androidx.media3.decoder)
+//            implementation(libs.androidx.media3.datasource)
+//            implementation(libs.androidx.media3.common)
+//            implementation(libs.androidx.media3.exoplayer.ima)
+//            implementation(libs.androidx.media3.ui.leanback)
+//            // encrypt
+//            implementation(libs.aescrypt)
+//            // permission
+//            implementation(libs.accompanist.permissions)
+//            // zxing
+//            implementation(libs.zxing.core)
+//            // svg
+//            implementation(libs.androidsvg.aar)
+//            // pallette
+//            implementation(libs.androidx.palette.ktx)
+//            // lottie
+//            implementation(libs.compose.lottie)
+//            // jsoup
+//            implementation(libs.jsoup)
+//            // dm
+//            implementation(libs.dailymotion.sdk.android)
+//            // dynamic theme
+//            implementation(libs.android.material)
+//            // widget
+//            implementation(libs.androidx.glance.appwidget)
+//            implementation(libs.androidx.glance.material)
+//            implementation(libs.androidx.glance.material3)
+//            // test
+//            testImplementation(libs.junit)
+//            androidTestImplementation(libs.androidx.junit)
+//            androidTestImplementation(libs.androidx.espresso.core)
+//            // anr
+//            implementation(libs.anrwatchdog)
+//            // oauth
+//            implementation(libs.auth0)
+//            implementation(libs.android.jwtdecode)
+        }
+    }
+}

+ 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()
-//        }
-
-    }
-
-}

+ 17 - 0
buildSrc/src/main/kotlin/org/mjdev/gradle/plugin/config/AppConfig.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.plugin.config
+
+open class AppConfig {
+    open var autoCorrectCode = false
+    open var createDocumentation = false
+
+    open var documentationDir = "documentation"
+    open var detectConfigFile = "config/detekt.ym"
+}

+ 17 - 0
buildSrc/src/main/kotlin/org/mjdev/gradle/plugin/config/LibConfig.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.plugin.config
+
+open class LibConfig {
+    open var autoCorrectCode = false
+    open var createDocumentation = false
+
+    open var documentationDir = "documentation"
+    open var detectConfigFile = "config/detekt.ym"
+}

+ 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()
+    }
+
+}

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

@@ -0,0 +1,44 @@
+/*
+ *  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 }
+//    }
+//
+//}

+ 56 - 0
buildSrc/src/main/kotlin/org/mjdev/gradle/tasks/InAppInfoCreateTask.kt

@@ -0,0 +1,56 @@
+/*
+ *  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.OutputDirectory
+import org.gradle.api.tasks.TaskAction
+import org.mjdev.gradle.extensions.packageName
+import org.mjdev.gradle.extensions.createFile
+import org.mjdev.gradle.extensions.srcOutputDirectory
+import java.io.File
+import org.mjdev.gradle.base.BaseTask
+import org.mjdev.gradle.extensions.write
+import org.mjdev.gradle.extensions.registerSourceDirectory
+
+@Suppress("MemberVisibilityCanBePrivate")
+open class InAppInfoCreateTask : BaseTask() {
+
+    @get:OutputDirectory
+    val srcOutputDir: File
+        get() = srcOutputDirectory
+
+    init {
+        group = "mjdev"
+        description = "Create ApplicationInfo class"
+        outputs.upToDateWhen { false }
+    }
+
+    @TaskAction
+    fun taskAction() {
+        registerSourceDirectory(srcOutputDir)
+        createFile(
+            name = "ApplicationInfo",
+            packageName = project.packageName
+        ) {
+            createClass(
+                name = "ApplicationInfo"
+            ) {
+                createFnc(
+                    name = "getInfo",
+                    returnType = String::class
+                ) {
+                    addStatement("return \"\"")
+                }
+            }
+        }.write(srcOutputDir) {
+            println("> Writing generated file: ${srcOutputDir}/${this.name}.kt")
+        }
+    }
+
+}

+ 51 - 0
buildSrc/src/main/kotlin/org/mjdev/gradle/tasks/ReleaseNotesCleanTask.kt

@@ -0,0 +1,51 @@
+/*
+ *  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.extensions.file
+import org.mjdev.gradle.extensions.projectName
+import org.mjdev.gradle.extensions.projectVersion
+import java.io.File
+import org.mjdev.gradle.base.BaseTask
+
+open class ReleaseNotesCleanTask : BaseTask() {
+
+    private val fileName: String
+        get() = "release-notes-$projectVersion.md"
+
+    private val docDir: File
+        get() = project.rootProject
+            .file("documentation")
+            .file(projectName)
+            .file("documentation")
+
+    private val outputFiles = listOf(
+        docDir.file(fileName)
+    )
+
+    init {
+        group = "mjdev"
+        description = "This task clear all release-notes files, to clean project."
+        outputs.upToDateWhen { false }
+    }
+
+    @TaskAction
+    fun taskAction() {
+        println("> Clearing release notes") // todo
+        outputFiles.forEach { file ->
+            if (file.isDirectory) {
+                file.deleteRecursively()
+            } else {
+                file.delete()
+            }
+        }
+    }
+
+}

+ 87 - 0
buildSrc/src/main/kotlin/org/mjdev/gradle/tasks/ReleaseNotesCreateTask.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.tasks
+
+import org.gradle.api.tasks.TaskAction
+import org.gradle.internal.impldep.org.eclipse.jgit.api.Git
+import org.gradle.internal.impldep.org.eclipse.jgit.internal.storage.file.FileRepository
+import org.mjdev.gradle.extensions.toDateString
+import java.io.File
+import java.util.Date
+import java.util.Locale
+import org.mjdev.gradle.extensions.file
+import org.mjdev.gradle.extensions.writeText
+import org.mjdev.gradle.extensions.projectVersion
+import org.mjdev.gradle.extensions.projectName
+import org.mjdev.gradle.base.BaseTask
+
+open class ReleaseNotesCreateTask : BaseTask() {
+
+    private val branchName = "main"
+    private val repo by lazy { FileRepository(project.rootProject.file(".git")) }
+    private val git: Git by lazy { Git(repo) }
+    private val branches by lazy { git.branchList().call() }
+
+    private val fileName: String
+        get() = "release-notes-$projectVersion.md"
+
+    private val docDir: File
+        get() = project.rootProject
+            .file("documentation")
+            .file(projectName)
+
+    private val outputFiles = listOf(
+        docDir.file(fileName)
+    )
+
+    init {
+        group = "mjdev"
+        description = "This task create release-notes files, for the project."
+        outputs.upToDateWhen { false }
+    }
+
+    @Suppress("DEPRECATION")
+    @TaskAction
+    fun taskAction() {
+        println("Creating release notes")
+        StringBuilder().apply {
+            append("** Release notes $projectVersion - ${Date().toLocaleString()}\n")
+            branches.forEach { ref ->
+                if (ref.name.endsWith(branchName)) {
+                    val commits = git.log().all().call().sortedByDescending {
+                        it.authorIdent.`when`
+                    }
+                    var lastDate: String? = null
+                    commits.forEach { commit ->
+                        val author = commit.authorIdent
+                            .name
+                            .toLowerCase(Locale.getDefault())
+                        val date = commit.authorIdent.`when`.toDateString()
+                        val message = commit.fullMessage
+                            .replace("\n", " ")
+                            .toLowerCase(Locale.getDefault())
+                        if (lastDate == null || !lastDate.contentEquals(date)) {
+                            lastDate = date
+                            append("\n")
+                            append("* $lastDate\n")
+                            append("\n")
+                        }
+                        append("* [$author] : $message\n")
+                    }
+                }
+            }
+        }.toString().let { text ->
+            outputFiles.forEach { of ->
+                println("> Creating release file: ${of.absolutePath}")
+                of.writeText(text)
+            }
+        }
+    }
+
+}

+ 46 - 0
buildSrc/src/main/kotlin/org/mjdev/gradle/tasks/TestCaseGeneratorTask.kt

@@ -0,0 +1,46 @@
+/*
+ *  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
+import org.mjdev.gradle.unittest.Worker
+import org.mjdev.gradle.unittest.WorkerImpl
+
+abstract class TestCaseGeneratorTask : BaseTask() {
+
+    private val urls
+        get() = restDependencies + javaCompiledClasses + kotlinCompiledClasses
+
+    private val sourceDirectoryList
+        get() = listOf(
+            "src/main/kotlin",
+            "src/main/java"
+        ).mapNotNull { path ->
+            val file = project.file(path)
+            if (file.exists()) file.absolutePath else null
+        }
+
+    private val exclude: List<String> = mutableListOf()
+
+    private var worker: Worker? = null
+
+    init {
+        group = "mjdev"
+        description = "Test classes generation tool"
+    }
+
+    @TaskAction
+    fun injectSource() {
+        worker = WorkerImpl(urls, sourceDirectoryList, exclude).apply {
+            work()
+        }
+    }
+
+}

+ 93 - 0
buildSrc/src/main/kotlin/org/mjdev/gradle/tasks/TestClassesGeneratorTask.kt

@@ -0,0 +1,93 @@
+/*
+ *  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 com.squareup.kotlinpoet.FileSpec
+import com.squareup.kotlinpoet.TypeSpec
+import org.gradle.api.Project
+import org.gradle.api.tasks.TaskAction
+import org.mjdev.gradle.base.BaseTask
+import org.mjdev.gradle.extensions.file
+import org.mjdev.gradle.extensions.allFiles
+import org.mjdev.gradle.extensions.unitTestDir
+import org.mjdev.gradle.extensions.asTestFile
+import java.io.File
+
+@Suppress("unused", "PrivatePropertyName")
+open class TestClassesGeneratorTask : BaseTask() {
+    private val GH_API_KEY = "0f2d28811bd14776b5c6fd4a84798d5b.96b1d61fc593b1ba"
+    private val GH_API_URL = "https://api-beta.copilot.com"
+
+    //curl \
+    //  -X POST \
+    //  -H "Content-Type: application/json" \
+    //  -H "X-API-KEY: <Replace this with your API Key>" \
+    //  --data '<Replace this with a json request body as needed>' \
+    //  https://api-beta.copilot.com/v1/<Replace this with a resource>
+
+    private val subprojects = project.rootProject.subprojects
+
+    private val fileMap = subprojects.associateWith { p ->
+        p.file("src").file("main").allFiles("kt")
+    }
+
+    private val worker = GithubClassWorker()
+
+
+    init {
+        group = "mjdev"
+        description = "This task create unit tests."
+        outputs.upToDateWhen { false }
+    }
+
+    @TaskAction
+    fun taskAction() {
+        println("> Got files:")
+        fileMap.keys.forEach { project ->
+            val files = fileMap[project] ?: emptyList()
+            files.forEach { file ->
+                println("> ${project.name} > $file")
+                createTestFile(project, file)
+            }
+        }
+    }
+
+    private fun createTestFile(
+        project: Project,
+        file: File
+    ) {
+        makeTestFile(
+            file,
+            File(project.unitTestDir, file.asTestFile.name)
+        )
+    }
+
+    @Suppress("SpellCheckingInspection")
+    private fun makeTestFile(
+        srcFile: File,
+        destfile: File
+    ) {
+        worker.makeTestClass(srcFile).also { createdClass ->
+            if (createdClass != null) {
+                createdClass.writeTo(destfile)
+            }
+        }
+    }
+
+    interface ClassWorker {
+        fun makeTestClass(file: File): FileSpec?
+    }
+
+    // todo
+
+    class GithubClassWorker : ClassWorker {
+        override fun makeTestClass(file: File): FileSpec? = null
+    }
+
+}

+ 38 - 0
buildSrc/src/main/kotlin/org/mjdev/gradle/tasks/ZipReleaseClearTask.kt

@@ -0,0 +1,38 @@
+/*
+ *  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 ZipReleaseClearTask : BaseTask() {
+
+    private val outputFiles = listOf(
+        project.rootProject.file("release.zip")
+    )
+
+    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()
+            }
+        }
+    }
+
+}

+ 47 - 0
buildSrc/src/main/kotlin/org/mjdev/gradle/tasks/ZipReleaseCreateTask.kt

@@ -0,0 +1,47 @@
+/*
+ *  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.bundling.Zip
+import org.mjdev.gradle.extensions.file
+import org.mjdev.gradle.extensions.projectVersion
+
+@Suppress("LeakingThis")
+open class ZipReleaseCreateTask : Zip() {
+
+    private val releaseFile = project.rootProject
+        .file("documentation")
+        .file("app")
+        .file("release-notes-$projectVersion.md")
+
+    private val readmeFile = project.rootProject
+        .file("readme.md")
+
+    private val files = arrayListOf(
+        releaseFile,
+        readmeFile,
+        project.rootProject.file("dependencies.txt"),
+        project.file("build").file("outputs").file("apk").file("debug"),
+        project.file("build").file("outputs").file("apk").file("release"),
+    )
+
+    init {
+        group = "mjdev"
+        description = "This task makes archive from build artifacts and wiki, and logs."
+
+        outputs.upToDateWhen { false }
+
+        from(files)
+        include("*", "*/*", "*/**")
+        includeEmptyDirs = true
+        destinationDirectory.set(project.rootProject.rootDir)
+        archiveFileName.set("release.zip")
+    }
+
+}

+ 16 - 0
buildSrc/src/main/kotlin/org/mjdev/gradle/unittest/ClassHelper.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.gradle.unittest
+
+import java.lang.reflect.Method
+
+internal interface ClassHelper {
+    fun getTestObjectMap(clazz: Class<*>): Map<String, Map<String, Class<*>>>
+    fun getMethodMap(clazz: Class<*>): Pair<Int, Map<String, Method>>
+}

+ 70 - 0
buildSrc/src/main/kotlin/org/mjdev/gradle/unittest/ClassHelperImpl.kt

@@ -0,0 +1,70 @@
+/*
+ *  Copyright (c) Milan Jurkulák 2024.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */
+
+package org.mjdev.gradle.unittest
+
+import org.mjdev.gradle.unittest.Constants.Companion.TEST_OBJECT_NAME
+import java.lang.reflect.Method
+import java.lang.reflect.Modifier
+
+internal class ClassHelperImpl : ClassHelper {
+    override fun getTestObjectMap(clazz: Class<*>): Map<String, Map<String, Class<*>>> {
+        val answer = LinkedHashMap<String, Map<String, Class<*>>>()
+        var i = 0
+        val parameterMap: MutableMap<Class<*>, Int> = HashMap()
+        for (constructor in clazz.declaredConstructors) {
+            if (constructor.isSynthetic || Modifier.isPrivate(constructor.modifiers)) continue
+            i += 1
+            val testObject = if (i == 1) TEST_OBJECT_NAME else TEST_OBJECT_NAME + i
+            val myParameters = LinkedHashMap<String, Class<*>>()
+            var j = 0
+            for (parameter in constructor.parameterTypes) {
+                if (!parameterMap.containsKey(parameter)) {
+                    parameterMap[parameter] = 1
+                } else {
+                    parameterMap[parameter] = parameterMap[parameter]!! + 1
+                }
+                val count = parameterMap[parameter]
+                val name = if (count == 1) {
+                    parameter.simpleName[0].lowercase() + parameter.simpleName.substring(1)
+                } else {
+                    parameter.simpleName[0].lowercase() + parameter.simpleName.substring(1) + count
+                }
+                myParameters[name] = parameter
+                j += 1
+            }
+            answer[testObject] = myParameters
+        }
+        return answer
+    }
+
+    override fun getMethodMap(clazz: Class<*>): Pair<Int, Map<String, Method>> {
+        val map = LinkedHashMap<String, Method>()
+        val methodNameMap: MutableMap<String, Int> = HashMap()
+        var methodCount = 0
+        for (method in clazz.declaredMethods) {
+            //Exhaustive list of non-private methods
+            if (!method.isSynthetic &&
+                !Modifier.isPrivate(method.modifiers) &&
+                !Modifier.isStatic(method.modifiers) &&
+                !Modifier.isAbstract(method.modifiers)
+            ) {
+                methodCount += 1
+                val name = if (!methodNameMap.containsKey(method.name)) {
+                    methodNameMap[method.name] = 1
+                    method.name
+                } else {
+                    methodNameMap[method.name] = methodNameMap[method.name]!! + 1
+                    method.name + methodNameMap[method.name]
+                }
+                map[name] = method
+            }
+        }
+        return Pair(methodCount, map)
+    }
+}

Einige Dateien werden nicht angezeigt, da zu viele Dateien in diesem Diff geändert wurden.