Browse Source

Update project dependencies, enhance analytics integration, and improve splash screen functionality

- Upgraded Gradle and Kotlin versions for better performance and compatibility.
- Added Umami analytics for tracking user interactions and events.
- Introduced a new splash screen theme and improved the MainActivity to handle splash screen visibility.
- Updated various dependencies including Jetpack Compose and Room for enhanced features.
- Refactored video handling logic and improved WebView client for better media playback experience.
- Added new bookmark database schema and integrated analytics tracking in BookmarkViewModel and SettingsViewModel.
- Enhanced Gradle configuration for better project structure and maintainability.
codeskraps 5 days ago
parent
commit
1f17ffe61a
35 changed files with 1144 additions and 200 deletions
  1. 6 0
      .idea/AndroidProjectSystem.xml
  2. 1 1
      .idea/compiler.xml
  3. 10 0
      .idea/deploymentTargetSelector.xml
  4. 1 1
      .idea/gradle.xml
  5. 1 1
      .idea/kotlinc.xml
  6. 2 1
      .idea/misc.xml
  7. 17 0
      .idea/runConfigurations.xml
  8. 10 3
      app/build.gradle.kts
  9. 52 0
      app/schemas/com.codeskraps.sbrowser.feature.bookmarks.data.local.BookmarkDB/1.json
  10. 2 1
      app/src/main/AndroidManifest.xml
  11. 6 0
      app/src/main/java/com/codeskraps/sbrowser/ForegroundService.kt
  12. 26 1
      app/src/main/java/com/codeskraps/sbrowser/MainActivity.kt
  13. 81 8
      app/src/main/java/com/codeskraps/sbrowser/feature/bookmarks/presentation/BookmarkViewModel.kt
  14. 84 6
      app/src/main/java/com/codeskraps/sbrowser/feature/settings/SettingsViewModel.kt
  15. 34 0
      app/src/main/java/com/codeskraps/sbrowser/feature/splash/SplashViewModel.kt
  16. 55 2
      app/src/main/java/com/codeskraps/sbrowser/feature/video/VideoViewModel.kt
  17. 0 1
      app/src/main/java/com/codeskraps/sbrowser/feature/video/components/VideoScreen.kt
  18. 1 0
      app/src/main/java/com/codeskraps/sbrowser/feature/video/mvi/VideoEvent.kt
  19. 4 2
      app/src/main/java/com/codeskraps/sbrowser/feature/video/mvi/VideoState.kt
  20. 50 8
      app/src/main/java/com/codeskraps/sbrowser/feature/webview/MediaWebViewModel.kt
  21. 27 19
      app/src/main/java/com/codeskraps/sbrowser/feature/webview/components/WebViewScreen.kt
  22. 67 13
      app/src/main/java/com/codeskraps/sbrowser/feature/webview/media/HandleVideo.kt
  23. 144 70
      app/src/main/java/com/codeskraps/sbrowser/feature/webview/media/MediaWebView.kt
  24. 137 48
      app/src/main/java/com/codeskraps/sbrowser/feature/webview/media/MediaWebViewClient.kt
  25. 1 0
      app/src/main/java/com/codeskraps/sbrowser/feature/webview/mvi/MediaWebViewEvent.kt
  26. 180 0
      app/src/main/java/com/codeskraps/sbrowser/umami/data/remote/UmamiAnalyticsDataSource.kt
  27. 31 0
      app/src/main/java/com/codeskraps/sbrowser/umami/data/repository/AnalyticsRepositoryImpl.kt
  28. 30 0
      app/src/main/java/com/codeskraps/sbrowser/umami/data/repository/DeviceIdRepositoryImpl.kt
  29. 45 0
      app/src/main/java/com/codeskraps/sbrowser/umami/di/CoreUmamiModule.kt
  30. 8 0
      app/src/main/java/com/codeskraps/sbrowser/umami/domain/AnalyticsRepository.kt
  31. 5 0
      app/src/main/java/com/codeskraps/sbrowser/umami/domain/DeviceIdRepository.kt
  32. 7 0
      app/src/main/res/values/themes.xml
  33. 1 1
      build.gradle.kts
  34. 17 12
      gradle/libs.versions.toml
  35. 1 1
      gradle/wrapper/gradle-wrapper.properties

+ 6 - 0
.idea/AndroidProjectSystem.xml

@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project version="4">
+  <component name="AndroidProjectSystem">
+    <option name="providerId" value="com.android.tools.idea.GradleProjectSystem" />
+  </component>
+</project>

+ 1 - 1
.idea/compiler.xml

@@ -1,6 +1,6 @@
 <?xml version="1.0" encoding="UTF-8"?>
 <project version="4">
   <component name="CompilerConfiguration">
-    <bytecodeTargetLevel target="17" />
+    <bytecodeTargetLevel target="21" />
   </component>
 </project>

+ 10 - 0
.idea/deploymentTargetSelector.xml

@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project version="4">
+  <component name="deploymentTargetSelector">
+    <selectionStates>
+      <SelectionState runConfigName="app">
+        <option name="selectionMode" value="DROPDOWN" />
+      </SelectionState>
+    </selectionStates>
+  </component>
+</project>

+ 1 - 1
.idea/gradle.xml

@@ -4,6 +4,7 @@
   <component name="GradleSettings">
     <option name="linkedExternalProjectsSettings">
       <GradleProjectSettings>
+        <option name="testRunner" value="CHOOSE_PER_TEST" />
         <option name="externalProjectPath" value="$PROJECT_DIR$" />
         <option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
         <option name="modules">
@@ -12,7 +13,6 @@
             <option value="$PROJECT_DIR$/app" />
           </set>
         </option>
-        <option name="resolveExternalAnnotations" value="false" />
       </GradleProjectSettings>
     </option>
   </component>

+ 1 - 1
.idea/kotlinc.xml

@@ -1,6 +1,6 @@
 <?xml version="1.0" encoding="UTF-8"?>
 <project version="4">
   <component name="KotlinJpsPluginSettings">
-    <option name="version" value="1.9.21" />
+    <option name="version" value="2.0.21" />
   </component>
 </project>

+ 2 - 1
.idea/misc.xml

@@ -1,6 +1,7 @@
+<?xml version="1.0" encoding="UTF-8"?>
 <project version="4">
   <component name="ExternalStorageConfigurationManager" enabled="true" />
-  <component name="ProjectRootManager" version="2" languageLevel="JDK_17" default="true" project-jdk-name="jbr-17" project-jdk-type="JavaSDK">
+  <component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
     <output url="file://$PROJECT_DIR$/build/classes" />
   </component>
   <component name="ProjectType">

+ 17 - 0
.idea/runConfigurations.xml

@@ -0,0 +1,17 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project version="4">
+  <component name="RunConfigurationProducerService">
+    <option name="ignoredProducers">
+      <set>
+        <option value="com.intellij.execution.junit.AbstractAllInDirectoryConfigurationProducer" />
+        <option value="com.intellij.execution.junit.AllInPackageConfigurationProducer" />
+        <option value="com.intellij.execution.junit.PatternConfigurationProducer" />
+        <option value="com.intellij.execution.junit.TestInClassConfigurationProducer" />
+        <option value="com.intellij.execution.junit.UniqueIdConfigurationProducer" />
+        <option value="com.intellij.execution.junit.testDiscovery.JUnitTestDiscoveryConfigurationProducer" />
+        <option value="org.jetbrains.kotlin.idea.junit.KotlinJUnitRunConfigurationProducer" />
+        <option value="org.jetbrains.kotlin.idea.junit.KotlinPatternConfigurationProducer" />
+      </set>
+    </option>
+  </component>
+</project>

+ 10 - 3
app/build.gradle.kts

@@ -1,19 +1,19 @@
-@Suppress("DSL_SCOPE_VIOLATION") // TODO: Remove once KTIJ-19369 is fixed
 plugins {
     alias(libs.plugins.androidApplication)
     alias(libs.plugins.kotlinAndroid)
     alias(libs.plugins.google.ksp)
     alias(libs.plugins.google.hilt)
+    alias(libs.plugins.compose.compiler)
 }
 
 android {
     namespace = "com.codeskraps.sbrowser"
-    compileSdk = 34
+    compileSdk = 35
 
     defaultConfig {
         applicationId = "com.codeskraps.sbrowser_new"
         minSdk = 26
-        targetSdk = 34
+        targetSdk = 35
         versionCode = 6
         versionName = "3.3"
         setProperty("archivesBaseName", "sBrowser-v$versionName.$versionCode")
@@ -22,6 +22,11 @@ android {
         vectorDrawables {
             useSupportLibrary = true
         }
+
+        // Room schema export configuration
+        ksp {
+            arg("room.schemaLocation", "$projectDir/schemas")
+        }
     }
 
     buildTypes {
@@ -69,6 +74,8 @@ dependencies {
     implementation(libs.ui.graphics)
     implementation(libs.ui.tooling.preview)
     implementation(libs.material3)
+    implementation(libs.webkit)
+    implementation(libs.androidx.core.splashscreen)
 
     //Dagger - Hilt
     implementation(libs.hilt.android)

+ 52 - 0
app/schemas/com.codeskraps.sbrowser.feature.bookmarks.data.local.BookmarkDB/1.json

@@ -0,0 +1,52 @@
+{
+  "formatVersion": 1,
+  "database": {
+    "version": 1,
+    "identityHash": "0abf97687625abb96ce41bc835457a58",
+    "entities": [
+      {
+        "tableName": "BookmarkEntity",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `title` TEXT NOT NULL, `url` TEXT NOT NULL, `image` BLOB)",
+        "fields": [
+          {
+            "fieldPath": "uid",
+            "columnName": "uid",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "title",
+            "columnName": "title",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "url",
+            "columnName": "url",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "image",
+            "columnName": "image",
+            "affinity": "BLOB",
+            "notNull": false
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": true,
+          "columnNames": [
+            "uid"
+          ]
+        },
+        "indices": [],
+        "foreignKeys": []
+      }
+    ],
+    "views": [],
+    "setupQueries": [
+      "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
+      "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '0abf97687625abb96ce41bc835457a58')"
+    ]
+  }
+}

+ 2 - 1
app/src/main/AndroidManifest.xml

@@ -14,13 +14,14 @@
         android:fullBackupContent="@xml/backup_rules"
         android:icon="@mipmap/ic_launcher"
         android:label="@string/app_name"
+        android:roundIcon="@mipmap/ic_launcher"
         android:supportsRtl="true"
         android:theme="@style/Theme.SBrowser"
         tools:targetApi="31">
         <activity
             android:name=".MainActivity"
             android:exported="true"
-            android:theme="@style/Theme.SBrowser">
+            android:theme="@style/Theme.SBrowser.Splash">
             <intent-filter>
                 <action android:name="android.intent.action.MAIN" />
 

+ 6 - 0
app/src/main/java/com/codeskraps/sbrowser/ForegroundService.kt

@@ -5,6 +5,7 @@ import android.app.NotificationChannel
 import android.app.NotificationManager
 import android.app.PendingIntent
 import android.app.Service
+import android.content.Context
 import android.content.Intent
 import android.os.IBinder
 import android.util.Log
@@ -24,6 +25,11 @@ class ForegroundService : Service() {
         private const val DELETE_EXTRA = "deleteExtra"
         private const val HOME_EXTRA = "homeExtra"
         private const val REFRESH_EXTRA = "refreshExtra"
+
+        fun createIntent(context: Context, url: String? = null): Intent =
+            Intent(context, ForegroundService::class.java).apply {
+                url?.let { putExtra(Constants.inputExtra, it) }
+            }
     }
 
     @Inject

+ 26 - 1
app/src/main/java/com/codeskraps/sbrowser/MainActivity.kt

@@ -3,13 +3,17 @@ package com.codeskraps.sbrowser
 import android.content.Intent
 import android.net.Uri
 import android.os.Bundle
+import android.view.View
+import android.view.ViewTreeObserver
 import androidx.activity.ComponentActivity
 import androidx.activity.compose.setContent
+import androidx.activity.viewModels
 import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.material3.MaterialTheme
 import androidx.compose.material3.Surface
 import androidx.compose.runtime.getValue
 import androidx.compose.ui.Modifier
+import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
 import androidx.hilt.navigation.compose.hiltViewModel
 import androidx.lifecycle.compose.collectAsStateWithLifecycle
 import androidx.navigation.NavType
@@ -21,6 +25,7 @@ import com.codeskraps.sbrowser.feature.bookmarks.presentation.BookmarkViewModel
 import com.codeskraps.sbrowser.feature.bookmarks.presentation.components.BookmarksScreen
 import com.codeskraps.sbrowser.feature.settings.SettingsViewModel
 import com.codeskraps.sbrowser.feature.settings.components.SettingsScreen
+import com.codeskraps.sbrowser.feature.splash.SplashViewModel
 import com.codeskraps.sbrowser.feature.video.VideoViewModel
 import com.codeskraps.sbrowser.feature.video.components.VideoScreen
 import com.codeskraps.sbrowser.feature.webview.MediaWebViewModel
@@ -42,9 +47,30 @@ class MainActivity : ComponentActivity() {
     @Inject
     lateinit var mediaWebViewPreferences: MediaWebViewPreferences
 
+    private val splashViewModel: SplashViewModel by viewModels()
+
     override fun onCreate(savedInstanceState: Bundle?) {
+        val splashScreen = installSplashScreen()
         super.onCreate(savedInstanceState)
 
+        // Set up the OnPreDrawListener to keep the splashscreen on-screen
+        val content: View = findViewById(android.R.id.content)
+        content.viewTreeObserver.addOnPreDrawListener(
+            object : ViewTreeObserver.OnPreDrawListener {
+                override fun onPreDraw(): Boolean {
+                    // Check if the initial data is ready
+                    return if (splashViewModel.isReady.value) {
+                        // The content is ready; start drawing
+                        content.viewTreeObserver.removeOnPreDrawListener(this)
+                        true
+                    } else {
+                        // The content isn't ready; suspend
+                        false
+                    }
+                }
+            }
+        )
+
         setContent {
             SBrowserTheme {
                 Surface(
@@ -77,7 +103,6 @@ class MainActivity : ComponentActivity() {
                             val data: Uri? = intent?.data
 
                             if (Intent.ACTION_VIEW == action && data != null) {
-
                                 val url = data.toString()
                                 if (url.startsWith("http://") || url.startsWith("https://")) {
                                     viewModel.state.handleEvent(MediaWebViewEvent.Load(url))

+ 81 - 8
app/src/main/java/com/codeskraps/sbrowser/feature/bookmarks/presentation/BookmarkViewModel.kt

@@ -7,6 +7,7 @@ import com.codeskraps.sbrowser.feature.bookmarks.presentation.mvi.BookmarkAction
 import com.codeskraps.sbrowser.feature.bookmarks.presentation.mvi.BookmarkEvent
 import com.codeskraps.sbrowser.feature.bookmarks.presentation.mvi.BookmarkState
 import com.codeskraps.sbrowser.feature.webview.media.MediaWebView
+import com.codeskraps.sbrowser.umami.domain.AnalyticsRepository
 import com.codeskraps.sbrowser.util.Resource
 import com.codeskraps.sbrowser.util.StateReducerViewModel
 import dagger.hilt.android.lifecycle.HiltViewModel
@@ -17,7 +18,8 @@ import javax.inject.Inject
 @HiltViewModel
 class BookmarkViewModel @Inject constructor(
     private val webView: MediaWebView,
-    private val localBookmarkRepository: LocalBookmarkRepository
+    private val localBookmarkRepository: LocalBookmarkRepository,
+    private val analyticsRepository: AnalyticsRepository
 ) : StateReducerViewModel<BookmarkState, BookmarkEvent, BookmarkAction>(BookmarkState.initial) {
 
     init {
@@ -41,9 +43,25 @@ class BookmarkViewModel @Inject constructor(
         currentState: BookmarkState,
         result: Resource<List<Bookmark>>
     ): BookmarkState {
-        return when (result) {
-            is Resource.Error -> currentState.setError(result.message)
-            is Resource.Success -> currentState.setBookmarks(result.data)
+        when (result) {
+            is Resource.Error -> {
+                viewModelScope.launch(Dispatchers.IO) {
+                    analyticsRepository.trackEvent(
+                        eventName = "bookmarks_load_error",
+                        eventData = mapOf("error" to result.message)
+                    )
+                }
+                return currentState.setError(result.message)
+            }
+            is Resource.Success -> {
+                viewModelScope.launch(Dispatchers.IO) {
+                    analyticsRepository.trackEvent(
+                        eventName = "bookmarks_loaded",
+                        eventData = mapOf("count" to result.data.size.toString())
+                    )
+                }
+                return currentState.setBookmarks(result.data)
+            }
         }
     }
 
@@ -52,6 +70,15 @@ class BookmarkViewModel @Inject constructor(
         val url = webView.url
 
         if (!url.isNullOrBlank() && !title.isNullOrBlank()) {
+            viewModelScope.launch(Dispatchers.IO) {
+                analyticsRepository.trackEvent(
+                    eventName = "bookmark_add",
+                    eventData = mapOf(
+                        "title" to title,
+                        "url" to url
+                    )
+                )
+            }
             saveBookmark(
                 bookmark = Bookmark(
                     uid = 0,
@@ -65,6 +92,15 @@ class BookmarkViewModel @Inject constructor(
     }
 
     private fun onEdit(currentState: BookmarkState, bookmark: Bookmark): BookmarkState {
+        viewModelScope.launch(Dispatchers.IO) {
+            analyticsRepository.trackEvent(
+                eventName = "bookmark_edit",
+                eventData = mapOf(
+                    "title" to bookmark.title,
+                    "url" to bookmark.url
+                )
+            )
+        }
         saveBookmark(bookmark = bookmark)
         return currentState
     }
@@ -73,22 +109,59 @@ class BookmarkViewModel @Inject constructor(
         viewModelScope.launch(Dispatchers.IO) {
             when (val result = localBookmarkRepository.saveBookmark(bookmark)) {
                 is Resource.Error -> {
+                    analyticsRepository.trackEvent(
+                        eventName = "bookmark_save_error",
+                        eventData = mapOf(
+                            "error" to result.message,
+                            "title" to bookmark.title,
+                            "url" to bookmark.url
+                        )
+                    )
                     actionChannel.send(BookmarkAction.Toast(result.message))
                 }
-
-                is Resource.Success -> {}
+                is Resource.Success -> {
+                    analyticsRepository.trackEvent(
+                        eventName = "bookmark_saved",
+                        eventData = mapOf(
+                            "title" to bookmark.title,
+                            "url" to bookmark.url
+                        )
+                    )
+                }
             }
         }
     }
 
     private fun onDelete(currentState: BookmarkState, bookmark: Bookmark): BookmarkState {
         viewModelScope.launch(Dispatchers.IO) {
+            analyticsRepository.trackEvent(
+                eventName = "bookmark_delete",
+                eventData = mapOf(
+                    "title" to bookmark.title,
+                    "url" to bookmark.url
+                )
+            )
             when (val result = localBookmarkRepository.deleteBookmark(bookmark)) {
                 is Resource.Error -> {
+                    analyticsRepository.trackEvent(
+                        eventName = "bookmark_delete_error",
+                        eventData = mapOf(
+                            "error" to result.message,
+                            "title" to bookmark.title,
+                            "url" to bookmark.url
+                        )
+                    )
                     actionChannel.send(BookmarkAction.Toast(result.message))
                 }
-
-                is Resource.Success -> {}
+                is Resource.Success -> {
+                    analyticsRepository.trackEvent(
+                        eventName = "bookmark_deleted",
+                        eventData = mapOf(
+                            "title" to bookmark.title,
+                            "url" to bookmark.url
+                        )
+                    )
+                }
             }
         }
         return currentState

+ 84 - 6
app/src/main/java/com/codeskraps/sbrowser/feature/settings/SettingsViewModel.kt

@@ -1,6 +1,5 @@
 package com.codeskraps.sbrowser.feature.settings
 
-import android.webkit.WebSettings.PluginState
 import android.webkit.WebView
 import androidx.lifecycle.viewModelScope
 import com.codeskraps.sbrowser.MediaWebViewPreferences
@@ -11,6 +10,7 @@ import com.codeskraps.sbrowser.feature.webview.media.ClearCookies
 import com.codeskraps.sbrowser.feature.webview.media.MediaWebView
 import com.codeskraps.sbrowser.feature.webview.media.TextSize
 import com.codeskraps.sbrowser.feature.webview.media.UserAgent
+import com.codeskraps.sbrowser.umami.domain.AnalyticsRepository
 import com.codeskraps.sbrowser.util.StateReducerViewModel
 import dagger.hilt.android.lifecycle.HiltViewModel
 import kotlinx.coroutines.Dispatchers
@@ -20,7 +20,8 @@ import javax.inject.Inject
 @HiltViewModel
 class SettingsViewModel @Inject constructor(
     private val mediaWebView: MediaWebView,
-    private val mediaWebViewPreferences: MediaWebViewPreferences
+    private val mediaWebViewPreferences: MediaWebViewPreferences,
+    private val analyticsRepository: AnalyticsRepository
 ) : StateReducerViewModel<SettingsState, SettingsEvent, SettingsAction>(SettingsState.initial) {
 
     init {
@@ -40,6 +41,20 @@ class SettingsViewModel @Inject constructor(
                     )
                 )
             )
+            
+            analyticsRepository.trackEvent(
+                eventName = "settings_loaded",
+                eventData = mapOf(
+                    "javascript_enabled" to mediaWebViewPreferences.javaScript.toString(),
+                    "text_size" to mediaWebViewPreferences.textSize.toString(),
+                    "user_agent" to mediaWebViewPreferences.userAgent.toString(),
+                    "dom_storage" to mediaWebViewPreferences.domStorage.toString(),
+                    "accept_cookies" to mediaWebViewPreferences.acceptCookies.toString(),
+                    "third_party_cookies" to mediaWebViewPreferences.thirdPartyCookies.toString(),
+                    "clear_cookies" to mediaWebViewPreferences.clearCookies.toString(),
+                    "show_url" to mediaWebViewPreferences.showUrl.toString()
+                )
+            )
         }
     }
 
@@ -60,31 +75,67 @@ class SettingsViewModel @Inject constructor(
 
     private fun onHome(currentState: SettingsState, url: String): SettingsState {
         mediaWebViewPreferences.homeUrl = url
+        viewModelScope.launch(Dispatchers.IO) {
+            analyticsRepository.trackEvent(
+                eventName = "settings_home_url_changed",
+                eventData = mapOf("url" to url)
+            )
+        }
         return currentState.copy(homeUrl = url)
     }
 
     private fun onJavaScript(currentState: SettingsState, value: Boolean): SettingsState {
-        mediaWebView.settings.javaScriptEnabled = value
+        mediaWebView.settings?.javaScriptEnabled = value
         mediaWebViewPreferences.javaScript = value
+        viewModelScope.launch(Dispatchers.IO) {
+            analyticsRepository.trackEvent(
+                eventName = "settings_javascript_changed",
+                eventData = mapOf("enabled" to value.toString())
+            )
+        }
         return currentState.copy(javaScript = value)
     }
 
     private fun onTextSize(currentState: SettingsState, value: TextSize): SettingsState {
-        mediaWebView.settings.textZoom = value.size
+        mediaWebView.settings?.textZoom = value.size
         mediaWebViewPreferences.textSize = value
+        viewModelScope.launch(Dispatchers.IO) {
+            analyticsRepository.trackEvent(
+                eventName = "settings_text_size_changed",
+                eventData = mapOf(
+                    "size" to value.toString(),
+                    "zoom_value" to value.size.toString()
+                )
+            )
+        }
         return currentState.copy(textSize = value)
     }
 
     private fun onUserAgent(currentState: SettingsState, value: UserAgent): SettingsState {
-        mediaWebView.settings.userAgentString = value.value
+        mediaWebView.settings?.userAgentString = value.value
         mediaWebViewPreferences.userAgent = value
         mediaWebView.reload()
+        viewModelScope.launch(Dispatchers.IO) {
+            analyticsRepository.trackEvent(
+                eventName = "settings_user_agent_changed",
+                eventData = mapOf(
+                    "agent" to value.toString(),
+                    "value" to value.value
+                )
+            )
+        }
         return currentState.copy(userAgent = value)
     }
 
     private fun onDomStorage(currentState: SettingsState, value: Boolean): SettingsState {
-        mediaWebView.settings.domStorageEnabled = value
+        mediaWebView.settings?.domStorageEnabled = value
         mediaWebViewPreferences.domStorage = value
+        viewModelScope.launch(Dispatchers.IO) {
+            analyticsRepository.trackEvent(
+                eventName = "settings_dom_storage_changed",
+                eventData = mapOf("enabled" to value.toString())
+            )
+        }
         return currentState.copy(domStorage = value)
     }
 
@@ -98,6 +149,15 @@ class SettingsViewModel @Inject constructor(
             if (!value) removeAllCookies {}
         }
         mediaWebViewPreferences.acceptCookies = value
+        viewModelScope.launch(Dispatchers.IO) {
+            analyticsRepository.trackEvent(
+                eventName = "settings_accept_cookies_changed",
+                eventData = mapOf(
+                    "enabled" to value.toString(),
+                    "cookies_cleared" to (!value).toString()
+                )
+            )
+        }
         return currentState.copy(acceptCookies = value)
     }
 
@@ -107,16 +167,34 @@ class SettingsViewModel @Inject constructor(
             value
         )
         mediaWebViewPreferences.thirdPartyCookies = value
+        viewModelScope.launch(Dispatchers.IO) {
+            analyticsRepository.trackEvent(
+                eventName = "settings_third_party_cookies_changed",
+                eventData = mapOf("enabled" to value.toString())
+            )
+        }
         return currentState.copy(thirdPartyCookies = value)
     }
 
     private fun onClearCookies(currentState: SettingsState, value: ClearCookies): SettingsState {
         mediaWebViewPreferences.clearCookies = value
+        viewModelScope.launch(Dispatchers.IO) {
+            analyticsRepository.trackEvent(
+                eventName = "settings_clear_cookies_option_changed",
+                eventData = mapOf("option" to value.toString())
+            )
+        }
         return currentState.copy(clearCookies = value)
     }
 
     private fun onShowUrl(currentState: SettingsState, value: Boolean): SettingsState {
         mediaWebViewPreferences.showUrl = value
+        viewModelScope.launch(Dispatchers.IO) {
+            analyticsRepository.trackEvent(
+                eventName = "settings_show_url_changed",
+                eventData = mapOf("enabled" to value.toString())
+            )
+        }
         return currentState.copy(showUrl = value)
     }
 }

+ 34 - 0
app/src/main/java/com/codeskraps/sbrowser/feature/splash/SplashViewModel.kt

@@ -0,0 +1,34 @@
+package com.codeskraps.sbrowser.feature.splash
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.codeskraps.sbrowser.umami.domain.AnalyticsRepository
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+@HiltViewModel
+class SplashViewModel @Inject constructor(
+    private val analyticsRepository: AnalyticsRepository
+) : ViewModel() {
+    private val _isReady = MutableStateFlow(false)
+    val isReady = _isReady.asStateFlow()
+
+    init {
+        initializeAnalytics()
+    }
+
+    private fun initializeAnalytics() {
+        try {
+            viewModelScope.launch {
+                analyticsRepository.initialize()
+            }
+            _isReady.value = true
+        } catch (e: Exception) {
+            // If analytics fails to initialize, we still want to proceed with the app
+            _isReady.value = true
+        }
+    }
+} 

+ 55 - 2
app/src/main/java/com/codeskraps/sbrowser/feature/video/VideoViewModel.kt

@@ -1,18 +1,25 @@
 package com.codeskraps.sbrowser.feature.video
 
 import androidx.lifecycle.SavedStateHandle
+import androidx.lifecycle.viewModelScope
 import com.codeskraps.sbrowser.feature.video.mvi.VideoAction
 import com.codeskraps.sbrowser.feature.video.mvi.VideoEvent
 import com.codeskraps.sbrowser.feature.video.mvi.VideoState
+import com.codeskraps.sbrowser.umami.domain.AnalyticsRepository
 import com.codeskraps.sbrowser.util.StateReducerViewModel
 import dagger.hilt.android.lifecycle.HiltViewModel
 import javax.inject.Inject
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
 
 @HiltViewModel
 class VideoViewModel @Inject constructor(
-    private val savedStateHandle: SavedStateHandle
+    private val savedStateHandle: SavedStateHandle,
+    private val analyticsRepository: AnalyticsRepository
 ) : StateReducerViewModel<VideoState, VideoEvent, VideoAction>(VideoState.initial) {
 
+    private var lastTrackedPosition: Long = 0
+
     init {
         savedStateHandle.get<String>("url")?.run {
             state.handleEvent(VideoEvent.Load(this))
@@ -22,12 +29,58 @@ class VideoViewModel @Inject constructor(
     override fun reduceState(currentState: VideoState, event: VideoEvent): VideoState {
         return when (event) {
             is VideoEvent.Load -> onLoad(currentState, event.url)
-            is VideoEvent.Position -> currentState.copy(position = event.position)
+            is VideoEvent.Position -> onPosition(currentState, event.position)
+            is VideoEvent.Duration -> onDuration(currentState, event.duration)
         }
     }
 
     private fun onLoad(currentState: VideoState, url: String): VideoState {
         savedStateHandle.remove<String>("url")
+        
+        viewModelScope.launch(Dispatchers.IO) {
+            analyticsRepository.trackPageView("video-player/$url")
+            analyticsRepository.trackEvent(
+                eventName = "video_started",
+                eventData = mapOf("url" to url)
+            )
+        }
+        
         return currentState.copy(url = url)
     }
+
+    private fun onPosition(currentState: VideoState, position: Long): VideoState {
+        // Only track position changes every 15 seconds to avoid excessive events
+        if (position - lastTrackedPosition >= 15_000 && currentState.duration > 0) {
+            viewModelScope.launch(Dispatchers.IO) {
+                val progressPercent = (position.toFloat() / currentState.duration * 100).toInt()
+
+                analyticsRepository.trackEvent(
+                    eventName = "video_progress",
+                    eventData = mapOf(
+                        "url" to currentState.url,
+                        "position_seconds" to (position / 1000).toString(),
+                        "duration_seconds" to (currentState.duration / 1000).toString(),
+                        "position_percent" to "$progressPercent%"
+                    )
+                )
+            }
+            lastTrackedPosition = position
+        }
+        return currentState.copy(position = position)
+    }
+
+    private fun onDuration(currentState: VideoState, duration: Long): VideoState {
+        if (duration > 0) {
+            viewModelScope.launch(Dispatchers.IO) {
+                analyticsRepository.trackEvent(
+                    eventName = "video_duration_set",
+                    eventData = mapOf(
+                        "url" to currentState.url,
+                        "duration_seconds" to (duration / 1000).toString()
+                    )
+                )
+            }
+        }
+        return currentState.copy(duration = duration)
+    }
 }

+ 0 - 1
app/src/main/java/com/codeskraps/sbrowser/feature/video/components/VideoScreen.kt

@@ -3,7 +3,6 @@ package com.codeskraps.sbrowser.feature.video.components
 import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.DisposableEffect
-import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.remember
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.platform.LocalContext

+ 1 - 0
app/src/main/java/com/codeskraps/sbrowser/feature/video/mvi/VideoEvent.kt

@@ -3,4 +3,5 @@ package com.codeskraps.sbrowser.feature.video.mvi
 sealed interface VideoEvent {
     data class Load(val url: String) : VideoEvent
     data class Position(val position: Long) : VideoEvent
+    data class Duration(val duration: Long) : VideoEvent
 }

+ 4 - 2
app/src/main/java/com/codeskraps/sbrowser/feature/video/mvi/VideoState.kt

@@ -2,12 +2,14 @@ package com.codeskraps.sbrowser.feature.video.mvi
 
 data class VideoState(
     val url: String,
-    val position: Long
+    val position: Long,
+    val duration: Long
 ) {
     companion object {
         val initial = VideoState(
             url = "",
-            position = 0
+            position = 0,
+            duration = 0
         )
     }
 }

+ 50 - 8
app/src/main/java/com/codeskraps/sbrowser/feature/webview/MediaWebViewModel.kt

@@ -1,7 +1,6 @@
 package com.codeskraps.sbrowser.feature.webview
 
 import android.content.Context
-import android.content.Intent
 import androidx.core.content.ContextCompat
 import androidx.lifecycle.SavedStateHandle
 import androidx.lifecycle.viewModelScope
@@ -11,8 +10,8 @@ import com.codeskraps.sbrowser.feature.webview.media.MediaWebView
 import com.codeskraps.sbrowser.feature.webview.mvi.MediaWebViewAction
 import com.codeskraps.sbrowser.feature.webview.mvi.MediaWebViewEvent
 import com.codeskraps.sbrowser.feature.webview.mvi.MediaWebViewState
+import com.codeskraps.sbrowser.umami.domain.AnalyticsRepository
 import com.codeskraps.sbrowser.util.BackgroundStatus
-import com.codeskraps.sbrowser.util.Constants
 import com.codeskraps.sbrowser.util.StateReducerViewModel
 import dagger.hilt.android.lifecycle.HiltViewModel
 import kotlinx.coroutines.Dispatchers
@@ -24,7 +23,8 @@ class MediaWebViewModel @Inject constructor(
     val mediaWebView: MediaWebView,
     private val backgroundStatus: BackgroundStatus,
     private val savedStateHandle: SavedStateHandle,
-    private val mediaWebViewPreferences: MediaWebViewPreferences
+    private val mediaWebViewPreferences: MediaWebViewPreferences,
+    private val analyticsRepository: AnalyticsRepository
 ) : StateReducerViewModel<MediaWebViewState, MediaWebViewEvent, MediaWebViewAction>(
     MediaWebViewState.initial
 ) {
@@ -63,12 +63,19 @@ class MediaWebViewModel @Inject constructor(
             is MediaWebViewEvent.DownloadService -> onDownloadService(currentState)
             is MediaWebViewEvent.ActionView -> onActionView(currentState)
             is MediaWebViewEvent.VideoPlayer -> onVideoPlayer(currentState, event.url)
+            is MediaWebViewEvent.Toast -> onToast(currentState, event.message)
         }
     }
 
     private fun onLoad(currentState: MediaWebViewState, url: String): MediaWebViewState {
-        mediaWebView.loadUrl(url.ifBlank { mediaWebViewPreferences.homeUrl })
+        val finalUrl = url.ifBlank { mediaWebViewPreferences.homeUrl }
+        mediaWebView.loadUrl(finalUrl)
         savedStateHandle.remove<String>("url")
+        
+        viewModelScope.launch(Dispatchers.IO) {
+            analyticsRepository.trackPageView(finalUrl)
+        }
+        
         return currentState
     }
 
@@ -79,14 +86,20 @@ class MediaWebViewModel @Inject constructor(
         val url = mediaWebView.url
         viewModelScope.launch(Dispatchers.IO) {
             if (!currentState.background) {
+                analyticsRepository.trackEvent(
+                    eventName = "background_service_start",
+                    eventData = mapOf("url" to (url ?: ""))
+                )
                 ContextCompat.startForegroundService(
                     context,
-                    Intent(context, ForegroundService::class.java).apply {
-                        putExtra(Constants.inputExtra, url)
-                    }
+                    ForegroundService.createIntent(context, url)
                 )
             } else {
-                context.stopService(Intent(context, ForegroundService::class.java))
+                analyticsRepository.trackEvent(
+                    eventName = "background_service_stop",
+                    eventData = mapOf("url" to (url ?: ""))
+                )
+                context.stopService(ForegroundService.createIntent(context))
             }
         }
         return currentState
@@ -94,6 +107,13 @@ class MediaWebViewModel @Inject constructor(
 
     private fun onPermission(currentState: MediaWebViewState): MediaWebViewState {
         viewModelScope.launch(Dispatchers.IO) {
+            analyticsRepository.trackEvent(
+                eventName = "permission_request",
+                eventData = mapOf(
+                    "url" to (mediaWebView.url ?: ""),
+                    "type" to "notification"
+                )
+            )
             actionChannel.send(MediaWebViewAction.Toast("Allow Notification Permission in the Device Settings for the app"))
         }
         return currentState
@@ -101,6 +121,10 @@ class MediaWebViewModel @Inject constructor(
 
     private fun onDownloadService(currentState: MediaWebViewState): MediaWebViewState {
         viewModelScope.launch(Dispatchers.IO) {
+            analyticsRepository.trackEvent(
+                eventName = "download_initiated",
+                eventData = mapOf("url" to (mediaWebView.url ?: ""))
+            )
             actionChannel.send(MediaWebViewAction.DownloadService)
         }
         return currentState
@@ -108,6 +132,10 @@ class MediaWebViewModel @Inject constructor(
 
     private fun onActionView(currentState: MediaWebViewState): MediaWebViewState {
         viewModelScope.launch(Dispatchers.IO) {
+            analyticsRepository.trackEvent(
+                eventName = "action_view",
+                eventData = mapOf("url" to (mediaWebView.url ?: ""))
+            )
             actionChannel.send(MediaWebViewAction.ActionView)
         }
         return currentState
@@ -115,11 +143,25 @@ class MediaWebViewModel @Inject constructor(
 
     private fun onVideoPlayer(currentState: MediaWebViewState, url: String): MediaWebViewState {
         viewModelScope.launch(Dispatchers.IO) {
+            analyticsRepository.trackEvent(
+                eventName = "video_player_launch",
+                eventData = mapOf(
+                    "current_url" to (mediaWebView.url ?: ""),
+                    "video_url" to url
+                )
+            )
             actionChannel.send(MediaWebViewAction.VideoPlayer(url))
         }
         return currentState
     }
 
+    private fun onToast(currentState: MediaWebViewState, message: String): MediaWebViewState {
+        viewModelScope.launch(Dispatchers.IO) {
+            actionChannel.send(MediaWebViewAction.Toast(message))
+        }
+        return currentState
+    }
+
     override fun onCleared() {
         super.onCleared()
         mediaWebView.setHandleListener(null)

+ 27 - 19
app/src/main/java/com/codeskraps/sbrowser/feature/webview/components/WebViewScreen.kt

@@ -31,6 +31,7 @@ import androidx.compose.ui.Modifier
 import androidx.compose.ui.platform.LocalConfiguration
 import androidx.compose.ui.platform.LocalContext
 import androidx.compose.ui.viewinterop.AndroidView
+import androidx.core.net.toUri
 import com.codeskraps.sbrowser.feature.webview.media.MediaWebView
 import com.codeskraps.sbrowser.feature.webview.mvi.MediaWebViewAction
 import com.codeskraps.sbrowser.feature.webview.mvi.MediaWebViewEvent
@@ -58,43 +59,50 @@ fun WebViewScreen(
         when (onAction) {
             is MediaWebViewAction.Toast -> {
                 scope.launch {
+                    val isSecurityMessage = onAction.message.startsWith("Security Warning")
                     val result = snackbarHostState.showSnackbar(
                         message = onAction.message,
-                        actionLabel = "Go",
+                        actionLabel = if (isSecurityMessage) "OK" else "Go",
                         withDismissAction = true,
                         duration = SnackbarDuration.Long
                     )
                     when (result) {
                         SnackbarResult.Dismissed -> {}
                         SnackbarResult.ActionPerformed -> {
-                            context.startActivity(Intent(ACTION_APPLICATION_DETAILS_SETTINGS).apply {
-                                data = Uri.fromParts("package", context.packageName, null)
-                                addCategory(CATEGORY_DEFAULT)
-                                addFlags(FLAG_ACTIVITY_NEW_TASK)
-                                addFlags(FLAG_ACTIVITY_NO_HISTORY)
-                                addFlags(FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS)
-                            })
+                            if (!isSecurityMessage) {
+                                context.startActivity(Intent(ACTION_APPLICATION_DETAILS_SETTINGS).apply {
+                                    data = Uri.fromParts("package", context.packageName, null)
+                                    addCategory(CATEGORY_DEFAULT)
+                                    addFlags(FLAG_ACTIVITY_NEW_TASK)
+                                    addFlags(FLAG_ACTIVITY_NO_HISTORY)
+                                    addFlags(FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS)
+                                })
+                            }
                         }
                     }
                 }
             }
 
             is MediaWebViewAction.DownloadService -> {
-                (context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager).run {
-                    enqueue(DownloadManager.Request(Uri.parse(mediaWebView.url)))
-                    context.startActivity(Intent().apply {
-                        setAction(DownloadManager.ACTION_VIEW_DOWNLOADS)
-                    })
+                mediaWebView.url?.let { url ->
+                    (context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager).run {
+                        enqueue(DownloadManager.Request(url.toUri()))
+                        context.startActivity(Intent().apply {
+                            setAction(DownloadManager.ACTION_VIEW_DOWNLOADS)
+                        })
+                    }
                 }
             }
 
             is MediaWebViewAction.ActionView -> {
-                context.startActivity(
-                    Intent(
-                        Intent.ACTION_VIEW,
-                        Uri.parse(mediaWebView.url)
-                    ).apply { flags = FLAG_ACTIVITY_NEW_TASK }
-                )
+                mediaWebView.url?.let { url ->
+                    context.startActivity(
+                        Intent(
+                            Intent.ACTION_VIEW,
+                            url.toUri()
+                        ).apply { flags = FLAG_ACTIVITY_NEW_TASK }
+                    )
+                }
             }
 
             is MediaWebViewAction.VideoPlayer -> {

+ 67 - 13
app/src/main/java/com/codeskraps/sbrowser/feature/webview/media/HandleVideo.kt

@@ -1,25 +1,48 @@
 package com.codeskraps.sbrowser.feature.webview.media
 
-import android.net.Uri
 import android.text.TextUtils
 import android.util.Log
 import org.jsoup.Jsoup
 import org.jsoup.nodes.Document
 import org.jsoup.nodes.Element
 import org.jsoup.select.Elements
-import java.io.IOException
 import java.util.regex.Matcher
 import java.util.regex.Pattern
 
-
 class HandleVideo {
     companion object {
         private val TAG = HandleVideo::class.java.simpleName
+        private val VIDEO_EXTENSIONS = arrayOf(".mp4", ".m3u8", ".webm", ".mov")
     }
 
     operator fun invoke(url: String, result: (String) -> Unit) {
         runCatching {
             val doc: Document = Jsoup.connect(url).get()
+            
+            // 1. Check for HTML5 video elements
+            doc.select("video source").forEach { source ->
+                source.attr("src").let { videoUrl ->
+                    if (isVideoUrl(videoUrl)) result(videoUrl)
+                }
+            }
+            
+            // 2. Check for video elements directly
+            doc.select("video").forEach { video ->
+                video.attr("src").let { videoUrl ->
+                    if (isVideoUrl(videoUrl)) result(videoUrl)
+                }
+            }
+
+            // 3. Check for iframes that might contain videos
+            doc.select("iframe").forEach { iframe ->
+                iframe.attr("src").let { iframeUrl ->
+                    if (iframeUrl.contains("player") || iframeUrl.contains("embed")) {
+                        result(iframeUrl)
+                    }
+                }
+            }
+
+            // Original player div check
             var metalinks: Elements = doc.select("div[id=player]")
 
             if (!metalinks.isEmpty()) {
@@ -55,6 +78,7 @@ class HandleVideo {
                 }
             }
 
+            // Check for play button
             metalinks = doc.select("a[id=play]")
 
             if (!metalinks.isEmpty()) {
@@ -64,6 +88,14 @@ class HandleVideo {
                 }
             }
 
+            // Check all links for video extensions
+            doc.select("a").forEach { link ->
+                link.attr("href").let { href ->
+                    if (isVideoUrl(href)) result(href)
+                }
+            }
+
+            // Script check for setVideoUrlHigh
             Log.e(TAG, "script")
             metalinks = doc.select("script")
 
@@ -74,16 +106,30 @@ class HandleVideo {
                     val html: String = iterator.next().html()
                     Log.w(TAG, "new:$html")
 
-                    if (!TextUtils.isEmpty(html) && html.contains("setVideoUrlHigh")) {
-                        val lines =
-                            html.split("\\r\\n|\\n|\\r".toRegex()).dropLastWhile { it.isEmpty() }
-                                .toTypedArray()
-
-                        for (line in lines) {
-
-                            if (line.contains("setVideoUrlHigh")) {
-                                Log.w(TAG, "setVideoUrlHigh:$line")
-                                result(line.substring(line.indexOf('(') + 2, line.indexOf(')') - 1))
+                    if (!TextUtils.isEmpty(html)) {
+                        // Check for setVideoUrlHigh
+                        if (html.contains("setVideoUrlHigh")) {
+                            val lines =
+                                html.split("\\r\\n|\\n|\\r".toRegex()).dropLastWhile { it.isEmpty() }
+                                    .toTypedArray()
+
+                            for (line in lines) {
+                                if (line.contains("setVideoUrlHigh")) {
+                                    Log.w(TAG, "setVideoUrlHigh:$line")
+                                    result(line.substring(line.indexOf('(') + 2, line.indexOf(')') - 1))
+                                }
+                            }
+                        }
+                        
+                        // Look for any URLs that might be video files
+                        val urlPattern = Pattern.compile(
+                            "https?://[^\\s<>\"']*?(?:${VIDEO_EXTENSIONS.joinToString("|")})",
+                            Pattern.CASE_INSENSITIVE
+                        )
+                        val matcher = urlPattern.matcher(html)
+                        while (matcher.find()) {
+                            matcher.group().let { videoUrl ->
+                                if (isVideoUrl(videoUrl)) result(videoUrl)
                             }
                         }
                     }
@@ -93,4 +139,12 @@ class HandleVideo {
             Log.e(TAG, "Handled - HandleVideo:$e", e)
         }
     }
+
+    private fun isVideoUrl(url: String): Boolean {
+        val lowercaseUrl = url.lowercase()
+        return VIDEO_EXTENSIONS.any { ext -> lowercaseUrl.endsWith(ext) } ||
+                lowercaseUrl.contains("video") ||
+                lowercaseUrl.contains("/media/") ||
+                lowercaseUrl.contains("stream")
+    }
 }

+ 144 - 70
app/src/main/java/com/codeskraps/sbrowser/feature/webview/media/MediaWebView.kt

@@ -6,43 +6,54 @@ import android.content.Context
 import android.content.res.Configuration
 import android.graphics.Bitmap
 import android.graphics.Canvas
-import android.os.Build
 import android.util.AttributeSet
 import android.view.View
 import android.view.ViewGroup
 import android.webkit.CookieManager
 import android.webkit.WebSettings
 import android.webkit.WebView
+import androidx.core.graphics.createBitmap
+import androidx.webkit.WebSettingsCompat
+import androidx.webkit.WebViewFeature
 import com.codeskraps.sbrowser.MediaWebViewPreferences
 import com.codeskraps.sbrowser.feature.webview.mvi.MediaWebViewEvent
 import java.io.ByteArrayOutputStream
 import javax.inject.Inject
+import androidx.core.view.ViewCompat
+import androidx.lifecycle.DefaultLifecycleObserver
+import androidx.lifecycle.LifecycleOwner
 
 @SuppressLint("SetJavaScriptEnabled")
 class MediaWebView @Inject constructor(
     private val application: Application,
     private val mediaWebViewPreferences: MediaWebViewPreferences
-) {
+) : DefaultLifecycleObserver {
 
-    private val webView by lazy {
-        InternalWebView(application).apply {
-            webViewClient = MediaWebViewClient()
-            webChromeClient = MediaWebChromeClient()
+    private var _webView: InternalWebView? = null
+    private val webView: InternalWebView
+        get() = _webView ?: InternalWebView(application).also {
+            it.setupWebView()
+            _webView = it
         }
-    }
 
     private var initLoad: Boolean = false
     val attachView: View
         get() = webView
     val url: String?
-        get() = webView.url
+        get() = _webView?.url
     val title: String?
-        get() = webView.title
-    val settings: WebSettings
-        get() = webView.settings
+        get() = _webView?.title
+    val settings: WebSettings?
+        get() = _webView?.settings
     val cookieManager: CookieManager
         get() = CookieManager.getInstance()
 
+    override fun onDestroy(owner: LifecycleOwner) {
+        _webView?.destroy()
+        _webView = null
+        super.onDestroy(owner)
+    }
+
     fun iniLoad() {
         if (!initLoad) {
             initLoad = true
@@ -51,18 +62,18 @@ class MediaWebView @Inject constructor(
     }
 
     fun setUrlListener(urlListener: ((String) -> Unit)?) {
-        (webView.webViewClient as MediaWebViewClient).urlListener = urlListener
+        (_webView?.webViewClient as? MediaWebViewClient)?.urlListener = urlListener
     }
 
     fun setHandleListener(handleEvent: ((MediaWebViewEvent) -> Unit)?) {
-        (webView.webChromeClient as MediaWebChromeClient).handleEvent = handleEvent
-        (webView.webViewClient as MediaWebViewClient).handleEvent = handleEvent
-        webView.javascriptInterface.handleEvent = handleEvent
+        (_webView?.webChromeClient as? MediaWebChromeClient)?.handleEvent = handleEvent
+        (_webView?.webViewClient as? MediaWebViewClient)?.handleEvent = handleEvent
+        _webView?.javascriptInterface?.handleEvent = handleEvent
     }
 
     fun detachView() {
-        webView.parent?.let {
-            (it as ViewGroup).removeView(webView)
+        _webView?.parent?.let {
+            (it as ViewGroup).removeView(_webView)
         }
     }
 
@@ -71,49 +82,47 @@ class MediaWebView @Inject constructor(
     }
 
     fun stopLoading() {
-        webView.stopLoading()
+        _webView?.stopLoading()
     }
 
     fun reload() {
-        webView.reload()
+        _webView?.reload()
     }
 
-    fun canGoBack(): Boolean = webView.canGoBack()
-    fun canGoForward(): Boolean = webView.canGoForward()
+    fun canGoBack(): Boolean = _webView?.canGoBack() ?: false
+    fun canGoForward(): Boolean = _webView?.canGoForward() ?: false
 
     fun goBack() {
-        webView.goBack()
+        if (canGoBack()) _webView?.goBack()
     }
 
     fun goForward() {
-        webView.goForward()
+        if (canGoForward()) _webView?.goForward()
     }
 
     fun capturePicture(): ByteArray? {
-        return runCatching {
-            val bitmap = Bitmap.createBitmap(300, 300, Bitmap.Config.ARGB_8888)
-            val canvas = Canvas(bitmap)
-            webView.draw(canvas)
-
-            val bos = ByteArrayOutputStream()
-            bitmap.compress(Bitmap.CompressFormat.PNG, 100, bos)
-            bitmap.isRecycled
-
-            bos.toByteArray()
-        }.getOrElse {
-            null
+        return _webView?.let { webView ->
+            runCatching {
+                createBitmap(300, 300).let { bitmap ->
+                    Canvas(bitmap).also { canvas ->
+                        webView.draw(canvas)
+                    }
+                    ByteArrayOutputStream().use { bos ->
+                        bitmap.compress(Bitmap.CompressFormat.PNG, 100, bos)
+                        bitmap.recycle()
+                        bos.toByteArray()
+                    }
+                }
+            }.getOrNull()
         }
     }
 
     inner class InternalWebView : WebView {
         constructor(context: Context?) : super(context!!)
-        constructor(context: Context?, attrs: AttributeSet?) : super(
-            context!!, attrs
-        )
+        constructor(context: Context?, attrs: AttributeSet?) : super(context!!, attrs)
+        constructor(context: Context?, attrs: AttributeSet?, defStyleAttr: Int) : super(context!!, attrs, defStyleAttr)
 
-        constructor(context: Context?, attrs: AttributeSet?, defStyleAttr: Int) : super(
-            context!!, attrs, defStyleAttr
-        )
+        val javascriptInterface = WebScriptInterface()
 
         override fun onWindowVisibilityChanged(visibility: Int) {
             if (visibility != View.GONE && visibility != View.INVISIBLE) {
@@ -121,39 +130,26 @@ class MediaWebView @Inject constructor(
             }
         }
 
-        val javascriptInterface = WebScriptInterface()
+        fun setupWebView() {
+            webViewClient = MediaWebViewClient()
+            webChromeClient = MediaWebChromeClient()
+            
+            // Enable hardware acceleration and scrolling
+            setLayerType(View.LAYER_TYPE_HARDWARE, null)
+            ViewCompat.setNestedScrollingEnabled(this, true)
+            isScrollbarFadingEnabled = true
+            scrollBarStyle = View.SCROLLBARS_INSIDE_OVERLAY
+
+            // Enable better touch handling
+            isHorizontalScrollBarEnabled = false
+            isVerticalScrollBarEnabled = true
+            isFocusable = true
+            isFocusableInTouchMode = true
 
-        init {
             with(settings) {
-                loadsImagesAutomatically = true
-                javaScriptEnabled = mediaWebViewPreferences.javaScript
-                domStorageEnabled = true
-                loadWithOverviewMode = true
-                useWideViewPort = true
-                builtInZoomControls = true
-                displayZoomControls = false
-                mediaPlaybackRequiresUserGesture = false
-                allowFileAccess = false
-                textZoom = mediaWebViewPreferences.textSize.size
-
-
-
-                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
-                    isAlgorithmicDarkeningAllowed = isDarkMode(context)
-                } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
-                    forceDark =
-                        if (isDarkMode(context)) WebSettings.FORCE_DARK_ON else WebSettings.FORCE_DARK_AUTO
-                }
-
-                setInitialScale(200)
-                setSupportZoom(true)
-                setNetworkAvailable(true)
-
-                userAgentString = mediaWebViewPreferences.userAgent.value
+                initializeWebSettings()
             }
 
-            //webView.addJavascriptInterface(WebScriptInterface(), "App")
-
             with(CookieManager.getInstance()) {
                 setAcceptCookie(mediaWebViewPreferences.acceptCookies)
                 setAcceptThirdPartyCookies(
@@ -162,6 +158,84 @@ class MediaWebView @Inject constructor(
                 )
             }
         }
+
+        private fun WebSettings.initializeWebSettings() {
+            // Basic settings
+            loadsImagesAutomatically = true
+            javaScriptEnabled = mediaWebViewPreferences.javaScript
+            domStorageEnabled = true
+            loadWithOverviewMode = true
+            useWideViewPort = true
+            builtInZoomControls = true
+            displayZoomControls = false
+            mediaPlaybackRequiresUserGesture = false
+            allowFileAccess = false
+            textZoom = mediaWebViewPreferences.textSize.size
+
+            // Enhanced web compatibility
+            javaScriptCanOpenWindowsAutomatically = true
+            databaseEnabled = true
+            domStorageEnabled = true
+            allowContentAccess = true
+            setSupportMultipleWindows(true)
+            allowFileAccessFromFileURLs = false
+            allowUniversalAccessFromFileURLs = false
+
+            // Performance and rendering improvements
+            setRenderPriority(WebSettings.RenderPriority.HIGH)
+            cacheMode = WebSettings.LOAD_NO_CACHE  // Try without cache first
+            setEnableSmoothTransition(true)
+            blockNetworkImage = false
+            blockNetworkLoads = false
+            
+            // Additional rendering settings
+            layoutAlgorithm = WebSettings.LayoutAlgorithm.NORMAL
+            standardFontFamily = "sans-serif"
+            defaultTextEncodingName = "UTF-8"
+            defaultFontSize = 16
+            minimumFontSize = 8
+            
+            // Modern web features
+            setupModernWebFeatures()
+
+            // Security settings with compatibility
+            mixedContentMode = WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE
+
+            // Display settings
+            setInitialScale(0)  // Let the WebView determine the proper scale
+            setSupportZoom(true)
+            setNetworkAvailable(true)
+
+            // Set a modern user agent if default is not provided
+            userAgentString = mediaWebViewPreferences.userAgent.value.takeIf { it.isNotBlank() }
+                ?: "Mozilla/5.0 (Linux; Android ${android.os.Build.VERSION.RELEASE}) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36"
+        }
+
+        private fun WebSettings.setupModernWebFeatures() {
+            if (WebViewFeature.isFeatureSupported(WebViewFeature.FORCE_DARK)) {
+                WebSettingsCompat.setForceDark(
+                    this,
+                    if (isDarkMode(context)) WebSettingsCompat.FORCE_DARK_ON
+                    else WebSettingsCompat.FORCE_DARK_OFF
+                )
+            }
+
+            if (WebViewFeature.isFeatureSupported(WebViewFeature.SAFE_BROWSING_ENABLE)) {
+                WebSettingsCompat.setSafeBrowsingEnabled(this, true)
+            }
+
+            if (WebViewFeature.isFeatureSupported(WebViewFeature.ALGORITHMIC_DARKENING)) {
+                WebSettingsCompat.setAlgorithmicDarkeningAllowed(this, isDarkMode(context))
+            }
+
+            // Enable additional modern features if available
+            if (WebViewFeature.isFeatureSupported(WebViewFeature.FORCE_DARK_STRATEGY)) {
+                WebSettingsCompat.setForceDarkStrategy(
+                    this,
+                    WebSettingsCompat.DARK_STRATEGY_WEB_THEME_DARKENING_ONLY
+                )
+            }
+        }
     }
 
     companion object {

+ 137 - 48
app/src/main/java/com/codeskraps/sbrowser/feature/webview/media/MediaWebViewClient.kt

@@ -1,90 +1,179 @@
 package com.codeskraps.sbrowser.feature.webview.media
 
+import android.annotation.SuppressLint
 import android.graphics.Bitmap
-import android.util.Base64
+import android.net.http.SslCertificate
+import android.net.http.SslError
+import android.util.Log
 import android.webkit.MimeTypeMap
+import android.webkit.SslErrorHandler
+import android.webkit.WebResourceError
 import android.webkit.WebResourceRequest
+import android.webkit.WebResourceResponse
 import android.webkit.WebView
 import android.webkit.WebViewClient
 import com.codeskraps.sbrowser.feature.webview.mvi.MediaWebViewEvent
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.launch
-import java.io.InputStream
+import java.io.ByteArrayInputStream
+import java.security.cert.Certificate
+import java.security.cert.CertificateException
+import java.security.cert.CertificateFactory
+import java.security.cert.X509Certificate
 import java.util.Locale
 
-
 class MediaWebViewClient : WebViewClient() {
 
     var urlListener: ((String) -> Unit)? = null
     var handleEvent: ((MediaWebViewEvent) -> Unit)? = null
 
+    private fun getX509Certificate(sslCertificate: SslCertificate): Certificate? {
+        val bundle = SslCertificate.saveState(sslCertificate)
+        val bytes = bundle.getByteArray("x509-certificate")
+        return bytes?.let {
+            try {
+                val certFactory = CertificateFactory.getInstance("X.509")
+                certFactory.generateCertificate(ByteArrayInputStream(it))
+            } catch (e: CertificateException) {
+                Log.e("SSL_ERROR", "Error generating certificate: ${e.message}")
+                null
+            }
+        }
+    }
+
     override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
         super.onPageStarted(view, url, favicon)
-        handleEvent?.let { it(MediaWebViewEvent.Loading(true)) }
-        urlListener?.let { url?.let { (url) } }
+        handleEvent?.let { handler ->
+            handler(MediaWebViewEvent.Loading(true))
+            url?.let { urlListener?.invoke(it) }
+        }
     }
 
     override fun onPageFinished(view: WebView?, url: String?) {
         super.onPageFinished(view, url)
         handleEvent?.let { it(MediaWebViewEvent.Loading(false)) }
+    }
 
-        //loadScript(view)
+    override fun onReceivedError(
+        view: WebView?,
+        request: WebResourceRequest?,
+        error: WebResourceError?
+    ) {
+        super.onReceivedError(view, request, error)
+        handleEvent?.let { it(MediaWebViewEvent.Loading(false)) }
     }
 
-    private fun loadScript(view: WebView?) {
-        view?.context?.assets?.let {
-            runCatching {
-                val inputStream: InputStream = it.open(
-                    "\$(\"video\").on(\"play\", function() { App.onVideoStart() });"
-                )
-                val buffer = ByteArray(inputStream.available())
-                inputStream.read(buffer)
-                inputStream.close()
-
-                val encoded = Base64.encodeToString(buffer, Base64.NO_WRAP)
-
-                view.loadUrl(
-                    "javascript:(function() {" +
-                            "var parent = document.getElementsByTagName('head').item(0);" +
-                            "var script = document.createElement('script');" +
-                            "script.type = 'text/javascript';" +
-                            "script.innerHTML = window.atob('$encoded');" +
-                            "parent.appendChild(script)" +
-                            "})()"
-                )
+    @SuppressLint("WebViewClientOnReceivedSslError")
+    override fun onReceivedSslError(
+        view: WebView?,
+        handler: SslErrorHandler?,
+        error: SslError?
+    ) {
+        if (error == null || handler == null) {
+            handler?.cancel()
+            return
+        }
+
+        // Get detailed error information
+        val errorMessage = when (error.primaryError) {
+            SslError.SSL_DATE_INVALID -> "SSL Certificate is not yet valid or has expired"
+            SslError.SSL_EXPIRED -> "SSL Certificate has expired"
+            SslError.SSL_IDMISMATCH -> "SSL Certificate hostname mismatch"
+            SslError.SSL_NOTYETVALID -> "SSL Certificate is not yet valid"
+            SslError.SSL_UNTRUSTED -> "SSL Certificate authority is not trusted"
+            SslError.SSL_INVALID -> "SSL Certificate is invalid"
+            else -> "Unknown SSL Certificate error"
+        }
+
+        // Try to verify the certificate
+        val certificate = error.certificate?.let { getX509Certificate(it) }
+        if (certificate != null) {
+            try {
+                // For X509Certificate, we can do additional checks
+                if (certificate is X509Certificate) {
+                    certificate.checkValidity()
+                    // Certificate is valid, we can proceed
+                    handler.proceed()
+                    handleEvent?.let { 
+                        it(MediaWebViewEvent.Toast("Certificate verified - Proceeding with caution"))
+                    }
+                    return
+                }
+            } catch (e: Exception) {
+                Log.e("SSL_ERROR", "Certificate validation failed: ${e.message}")
             }
         }
+
+        // If we couldn't verify the certificate or verification failed, cancel the connection
+        handler.cancel()
+        handleEvent?.let { 
+            it(MediaWebViewEvent.Loading(false))
+            it(MediaWebViewEvent.Toast("Security Warning: $errorMessage"))
+        }
     }
 
     override fun shouldOverrideUrlLoading(
         view: WebView?,
         request: WebResourceRequest?
     ): Boolean {
-        val url = request?.url.toString()
-        val fileExtension =
-            MimeTypeMap.getFileExtensionFromUrl(url).lowercase(Locale.getDefault())
-
-        if ("mp4" == fileExtension || "3gp" == fileExtension) {
-            handleEvent?.let { it(MediaWebViewEvent.VideoPlayer(url)) }
-            return true
-
-        } else if ("ppt" == fileExtension || "doc" == fileExtension || "pdf" == fileExtension || "apk" == fileExtension) {
-            handleEvent?.let { it(MediaWebViewEvent.DownloadService) }
-            return true
-
-        } else if (!url.startsWith("http://") && !url.startsWith("https://")) {
-            handleEvent?.let { it(MediaWebViewEvent.ActionView) }
-            return true
-
-        } else if (url != "about:blank" && url != view!!.url) {
-            CoroutineScope(Dispatchers.IO).launch {
-                HandleVideo()(url) { video ->
-                    handleEvent?.let { it(MediaWebViewEvent.VideoPlayer(video)) }
+        if (view == null || request == null) return false
+        
+        val url = request.url.toString()
+        if (url.isBlank()) return false
+
+        // Handle special schemes
+        when {
+            url.startsWith("tel:") -> {
+                handleEvent?.let { it(MediaWebViewEvent.ActionView) }
+                return true
+            }
+            url.startsWith("mailto:") -> {
+                handleEvent?.let { it(MediaWebViewEvent.ActionView) }
+                return true
+            }
+            url.startsWith("sms:") -> {
+                handleEvent?.let { it(MediaWebViewEvent.ActionView) }
+                return true
+            }
+        }
+
+        val fileExtension = MimeTypeMap.getFileExtensionFromUrl(url).lowercase(Locale.getDefault())
+
+        return when {
+            // Handle media files
+            fileExtension in listOf("mp4", "3gp", "webm", "mkv") -> {
+                handleEvent?.let { it(MediaWebViewEvent.VideoPlayer(url)) }
+                true
+            }
+            // Handle documents
+            fileExtension in listOf("pdf", "doc", "docx", "ppt", "pptx", "xls", "xlsx", "apk") -> {
+                handleEvent?.let { it(MediaWebViewEvent.DownloadService) }
+                true
+            }
+            // Handle non-web protocols
+            !url.startsWith("http://") && !url.startsWith("https://") -> {
+                handleEvent?.let { it(MediaWebViewEvent.ActionView) }
+                true
+            }
+            // Handle potential video content on web pages
+            url != "about:blank" && url != view.url -> {
+                CoroutineScope(Dispatchers.IO).launch {
+                    HandleVideo()(url) { video ->
+                        handleEvent?.let { it(MediaWebViewEvent.VideoPlayer(video)) }
+                    }
                 }
+                false // Let the WebView load the page while we check for video
             }
+            else -> false // Let WebView handle the URL
         }
+    }
 
-        return super.shouldOverrideUrlLoading(view, request)
+    override fun shouldInterceptRequest(
+        view: WebView?,
+        request: WebResourceRequest?
+    ): WebResourceResponse? {
+        // Add any content blocking or request modification logic here if needed
+        return super.shouldInterceptRequest(view, request)
     }
 }

+ 1 - 0
app/src/main/java/com/codeskraps/sbrowser/feature/webview/mvi/MediaWebViewEvent.kt

@@ -13,4 +13,5 @@ sealed interface MediaWebViewEvent {
     data class VideoPlayer(val url: String) : MediaWebViewEvent
     data object DownloadService : MediaWebViewEvent
     data object ActionView : MediaWebViewEvent
+    data class Toast(val message: String) : MediaWebViewEvent
 }

+ 180 - 0
app/src/main/java/com/codeskraps/sbrowser/umami/data/remote/UmamiAnalyticsDataSource.kt

@@ -0,0 +1,180 @@
+package com.codeskraps.sbrowser.umami.data.remote
+
+import android.annotation.SuppressLint
+import android.content.Context
+import android.webkit.WebView
+import androidx.webkit.WebViewClientCompat
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.withContext
+import kotlinx.coroutines.suspendCancellableCoroutine
+
+internal data class UmamiConfig(
+    val scriptUrl: String,
+    val websiteId: String,
+    val baseUrl: String
+)
+
+@SuppressLint("SetJavaScriptEnabled")
+internal class UmamiAnalyticsDataSource(
+    private val context: Context,
+    private val config: UmamiConfig
+) {
+
+    private var isInitialized = false
+
+    private val webView: WebView by lazy {
+        WebView(context).apply {
+            settings.javaScriptEnabled = true
+            webViewClient = UmamiWebViewClient()
+            loadUrl("about:blank")
+        }
+    }
+
+    private val umamiScript = """
+        <script defer src="${config.scriptUrl}" data-website-id="${config.websiteId}"></script>
+    """.trimIndent()
+
+    @OptIn(ExperimentalCoroutinesApi::class)
+    suspend fun initialize() = withContext(Dispatchers.Main) {
+        if (isInitialized) return@withContext
+
+        webView.loadDataWithBaseURL(
+            config.baseUrl,
+            "<html><head>$umamiScript</head><body></body></html>",
+            "text/html",
+            "UTF-8",
+            null
+        )
+
+        // Check if Umami is ready by evaluating JavaScript
+        var attempts = 0
+        val maxAttempts = 10 // Maximum number of attempts
+
+        while (attempts < maxAttempts) {
+            try {
+                val isUmamiReady = suspendCancellableCoroutine { continuation ->
+                    webView.evaluateJavascript(
+                        """
+                        (function() {
+                            return typeof umami !== 'undefined';
+                        })();
+                        """.trimIndent()
+                    ) { result ->
+                        continuation.resume(result.toBooleanStrictOrNull() ?: false, null)
+                    }
+                }
+
+                if (isUmamiReady) {
+                    isInitialized = true
+                    break
+                }
+            } catch (e: Exception) {
+                // Log error if needed
+            }
+
+            attempts++
+            delay(200) // Short delay between checks
+        }
+    }
+
+    suspend fun trackPageView(pageName: String) = withContext(Dispatchers.Main) {
+        if (!isInitialized) return@withContext
+
+        webView.evaluateJavascript(
+            """
+            (function() {
+                if (typeof umami === 'undefined') {
+                    console.error('Umami is not defined');
+                    return false;
+                }
+                
+                try {
+                    // Format the page name as a proper URL path
+                    const path = '$pageName'.startsWith('/') ? '$pageName' : '/$pageName';
+                    // Create a title from the page name (capitalize first letter, replace dashes with spaces)
+                    const title = '$pageName'
+                        .replace(/-/g, ' ')
+                        .replace(/\\b\\w/g, l => l.toUpperCase());
+                    
+                    umami.track({ 
+                        url: path, 
+                        title: title,
+                        website: '${config.websiteId}'
+                    });
+                    console.log('Page view tracked:', path, 'with title:', title);
+                    return true;
+                } catch (e) {
+                    console.error('Error tracking page view:', e);
+                    return false;
+                }
+            })();
+            """,
+            null
+        )
+    }
+
+    suspend fun trackEvent(eventName: String, eventData: Map<String, String> = emptyMap()) = withContext(Dispatchers.Main) {
+        if (!isInitialized) return@withContext
+
+        webView.evaluateJavascript(
+            """
+            (function() {
+                if (typeof umami === 'undefined') {
+                    console.error('Umami is not defined');
+                    return false;
+                }
+                
+                try {
+                    const data = ${eventData.entries.joinToString(",", "{", "}") {
+                "\"${it.key}\": \"${it.value}\""
+            }};
+                    umami.track('$eventName', data, '${config.websiteId}');
+                    console.log('Event tracked:', '$eventName', data);
+                    return true;
+                } catch (e) {
+                    console.error('Error tracking event:', e);
+                    return false;
+                }
+            })();
+            """,
+            null
+        )
+    }
+
+    suspend fun identifyUser(walletAddress: String?) = withContext(Dispatchers.Main) {
+        if (!isInitialized || walletAddress.isNullOrBlank()) return@withContext
+
+        // Anonymize the address by using only the first and last 4 characters
+        val addressLength = walletAddress.length
+        val anonymizedId = if (addressLength > 8) {
+            "${walletAddress.take(4)}...${walletAddress.takeLast(4)}"
+        } else {
+            walletAddress
+        }
+
+        webView.evaluateJavascript(
+            """
+            (function() {
+                if (typeof umami === 'undefined') {
+                    console.error('Umami is not defined');
+                    return false;
+                }
+                
+                try {
+                    umami.identify({ wallet_id: '$anonymizedId' }, '${config.websiteId}');
+                    console.log('User identified:', '$anonymizedId');
+                    return true;
+                } catch (e) {
+                    console.error('Error identifying user:', e);
+                    return false;
+                }
+            })();
+            """,
+            null
+        )
+    }
+
+    private class UmamiWebViewClient : WebViewClientCompat()
+}

+ 31 - 0
app/src/main/java/com/codeskraps/sbrowser/umami/data/repository/AnalyticsRepositoryImpl.kt

@@ -0,0 +1,31 @@
+package com.codeskraps.sbrowser.umami.data.repository
+
+import com.codeskraps.sbrowser.umami.data.remote.UmamiAnalyticsDataSource
+import com.codeskraps.sbrowser.umami.domain.AnalyticsRepository
+import com.codeskraps.sbrowser.umami.domain.DeviceIdRepository
+import javax.inject.Inject
+
+internal class AnalyticsRepositoryImpl @Inject constructor(
+    private val analyticsDataSource: UmamiAnalyticsDataSource,
+    private val deviceIdRepository: DeviceIdRepository
+) : AnalyticsRepository {
+
+    override suspend fun initialize() {
+        analyticsDataSource.initialize()
+        // Identify the device as soon as analytics is initialized
+        val deviceId = deviceIdRepository.getOrCreateDeviceId()
+        identifyUser(deviceId)
+    }
+
+    override suspend fun trackPageView(pageName: String) {
+        analyticsDataSource.trackPageView(pageName)
+    }
+
+    override suspend fun trackEvent(eventName: String, eventData: Map<String, String>) {
+        analyticsDataSource.trackEvent(eventName, eventData)
+    }
+
+    override suspend fun identifyUser(walletAddress: String?) {
+        analyticsDataSource.identifyUser(walletAddress)
+    }
+}

+ 30 - 0
app/src/main/java/com/codeskraps/sbrowser/umami/data/repository/DeviceIdRepositoryImpl.kt

@@ -0,0 +1,30 @@
+package com.codeskraps.sbrowser.umami.data.repository
+
+import android.content.Context
+import android.content.SharedPreferences
+import androidx.core.content.edit
+import com.codeskraps.sbrowser.umami.domain.DeviceIdRepository
+import java.util.UUID
+import javax.inject.Inject
+
+class DeviceIdRepositoryImpl @Inject constructor(
+    context: Context
+) : DeviceIdRepository {
+
+    private val prefs: SharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
+
+    override suspend fun getOrCreateDeviceId(): String {
+        return prefs.getString(KEY_DEVICE_ID, null) ?: generateAndSaveDeviceId()
+    }
+
+    private fun generateAndSaveDeviceId(): String {
+        val deviceId = UUID.randomUUID().toString()
+        prefs.edit { putString(KEY_DEVICE_ID, deviceId) }
+        return deviceId
+    }
+
+    companion object {
+        private const val PREFS_NAME = "weather_device_prefs"
+        private const val KEY_DEVICE_ID = "device_id"
+    }
+} 

+ 45 - 0
app/src/main/java/com/codeskraps/sbrowser/umami/di/CoreUmamiModule.kt

@@ -0,0 +1,45 @@
+package com.codeskraps.sbrowser.umami.di
+import android.app.Application
+import com.codeskraps.sbrowser.umami.data.remote.UmamiAnalyticsDataSource
+import com.codeskraps.sbrowser.umami.data.remote.UmamiConfig
+import com.codeskraps.sbrowser.umami.data.repository.AnalyticsRepositoryImpl
+import com.codeskraps.sbrowser.umami.data.repository.DeviceIdRepositoryImpl
+import com.codeskraps.sbrowser.umami.domain.AnalyticsRepository
+import com.codeskraps.sbrowser.umami.domain.DeviceIdRepository
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+object CoreUmamiModule {
+
+    @Provides
+    @Singleton
+    fun providesDeviceIdRepository(
+        app: Application
+    ): DeviceIdRepository {
+        return DeviceIdRepositoryImpl(app)
+    }
+
+    @Provides
+    @Singleton
+    fun providesAnalyticsRepository(
+        app: Application,
+        deviceIdRepository: DeviceIdRepository
+    ): AnalyticsRepository {
+        return AnalyticsRepositoryImpl(
+            UmamiAnalyticsDataSource(
+                context = app,
+                config = UmamiConfig(
+                    scriptUrl = "https://umami.codeskraps.com/script.js",
+                    websiteId = "ec28ad88-cb69-4211-8b6c-af29f58d82c2",
+                    baseUrl = "https://umami.codeskraps.com"
+                )
+            ),
+            deviceIdRepository = deviceIdRepository
+        )
+    }
+}

+ 8 - 0
app/src/main/java/com/codeskraps/sbrowser/umami/domain/AnalyticsRepository.kt

@@ -0,0 +1,8 @@
+package com.codeskraps.sbrowser.umami.domain
+
+interface AnalyticsRepository {
+    suspend fun initialize()
+    suspend fun trackPageView(pageName: String)
+    suspend fun trackEvent(eventName: String, eventData: Map<String, String> = emptyMap())
+    suspend fun identifyUser(walletAddress: String?)
+}

+ 5 - 0
app/src/main/java/com/codeskraps/sbrowser/umami/domain/DeviceIdRepository.kt

@@ -0,0 +1,5 @@
+package com.codeskraps.sbrowser.umami.domain
+
+interface DeviceIdRepository {
+    suspend fun getOrCreateDeviceId(): String
+} 

+ 7 - 0
app/src/main/res/values/themes.xml

@@ -4,4 +4,11 @@
     <style name="Theme.SBrowser" parent="android:Theme.Material.Light.NoActionBar">
         <item name="android:windowBackground">@color/window_background</item>
     </style>
+
+    <style name="Theme.SBrowser.Splash" parent="Theme.SplashScreen">
+        <item name="windowSplashScreenBackground">@android:color/white</item>
+        <item name="windowSplashScreenAnimatedIcon">@mipmap/ic_launcher</item>
+        <item name="windowSplashScreenAnimationDuration">1000</item>
+        <item name="postSplashScreenTheme">@style/Theme.SBrowser</item>
+    </style>
 </resources>

+ 1 - 1
build.gradle.kts

@@ -1,10 +1,10 @@
 // Top-level build file where you can add configuration options common to all sub-projects/modules.
-@Suppress("DSL_SCOPE_VIOLATION") // TODO: Remove once KTIJ-19369 is fixed
 plugins {
     alias(libs.plugins.androidApplication) apply false
     alias(libs.plugins.kotlinAndroid) apply false
     alias(libs.plugins.google.ksp) apply false
     alias(libs.plugins.google.hilt) apply false
+    alias(libs.plugins.compose.compiler) apply false
 }
 
 tasks.register("clean", Delete::class) {

+ 17 - 12
gradle/libs.versions.toml

@@ -1,21 +1,24 @@
 [versions]
-agp = "8.3.0"
-kotlin = "1.9.21"
-core-ktx = "1.12.0"
+agp = "8.9.1"
+core-splashscreen = "1.0.1"
+kotlin = "2.0.21"
+core-ktx = "1.15.0"
 junit = "4.13.2"
-androidx-test-ext-junit = "1.1.5"
-espresso-core = "3.5.1"
-lifecycle-runtime-ktx = "2.7.0"
-activity-compose = "1.8.2"
-compose-bom = "2024.02.02"
-ksp = "1.9.21-1.0.16"
-hilt = "2.51"
+androidx-test-ext-junit = "1.2.1"
+espresso-core = "3.6.1"
+lifecycle-runtime-ktx = "2.8.7"
+activity-compose = "1.10.1"
+compose-bom = "2025.03.01"
+ksp = "2.0.21-1.0.28"
+hilt = "2.52"
 hilt-navigation = "1.2.0"
 room = "2.6.1"
 jsoup = "1.17.2"
-media3_version = "1.3.0"
+media3_version = "1.6.0"
+webkit = "1.13.0"
 
 [libraries]
+androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "core-splashscreen" }
 core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "core-ktx" }
 junit = { group = "junit", name = "junit", version.ref = "junit" }
 androidx-test-ext-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidx-test-ext-junit" }
@@ -45,9 +48,11 @@ room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
 jsoup = { group = "org.jsoup", name = "jsoup", version.ref = "jsoup" }
 media3-exoplayer = { group = "androidx.media3", name = "media3-exoplayer", version.ref = "media3_version" }
 media3-ui = { group = "androidx.media3", name = "media3-ui", version.ref = "media3_version" }
+webkit = { group = "androidx.webkit", name = "webkit", version.ref = "webkit" }
 
 [plugins]
 androidApplication = { id = "com.android.application", version.ref = "agp" }
 kotlinAndroid = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
 google-ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
-google-hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
+google-hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
+compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }

+ 1 - 1
gradle/wrapper/gradle-wrapper.properties

@@ -1,6 +1,6 @@
 #Thu Jan 18 15:41:38 CET 2024
 distributionBase=GRADLE_USER_HOME
 distributionPath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
 zipStoreBase=GRADLE_USER_HOME
 zipStorePath=wrapper/dists