ソースを参照

Migration to Kotlin and Compose

Carles Sentis 11 ヶ月 前
コミット
e3c084e2a4
100 ファイル変更3440 行追加0 行削除
  1. 15 0
      .gitignore
  2. 3 0
      .idea/.gitignore
  3. 6 0
      .idea/compiler.xml
  4. 10 0
      .idea/deploymentTargetDropDown.xml
  5. 19 0
      .idea/gradle.xml
  6. 32 0
      .idea/inspectionProfiles/Project_Default.xml
  7. 6 0
      .idea/kotlinc.xml
  8. 10 0
      .idea/migrations.xml
  9. 9 0
      .idea/misc.xml
  10. 1 0
      app/.gitignore
  11. 91 0
      app/build.gradle.kts
  12. 21 0
      app/proguard-rules.pro
  13. 24 0
      app/src/androidTest/java/com/codeskraps/sbrowser/ExampleInstrumentedTest.kt
  14. 39 0
      app/src/main/AndroidManifest.xml
  15. 141 0
      app/src/main/java/com/codeskraps/sbrowser/ForegroundService.kt
  16. 104 0
      app/src/main/java/com/codeskraps/sbrowser/MainActivity.kt
  17. 64 0
      app/src/main/java/com/codeskraps/sbrowser/MediaWebViewPreferences.kt
  18. 7 0
      app/src/main/java/com/codeskraps/sbrowser/SBrowserApp.kt
  19. 39 0
      app/src/main/java/com/codeskraps/sbrowser/di/AppModule.kt
  20. 13 0
      app/src/main/java/com/codeskraps/sbrowser/feature/bookmarks/data/local/BookmarkDB.kt
  21. 20 0
      app/src/main/java/com/codeskraps/sbrowser/feature/bookmarks/data/local/BookmarkDao.kt
  22. 39 0
      app/src/main/java/com/codeskraps/sbrowser/feature/bookmarks/data/local/BookmarkEntity.kt
  23. 22 0
      app/src/main/java/com/codeskraps/sbrowser/feature/bookmarks/data/mappers/BookmarkMapper.kt
  24. 50 0
      app/src/main/java/com/codeskraps/sbrowser/feature/bookmarks/data/repository/LocalBookmarkRepositoryImpl.kt
  25. 29 0
      app/src/main/java/com/codeskraps/sbrowser/feature/bookmarks/di/BookmarkFeatureModule.kt
  26. 18 0
      app/src/main/java/com/codeskraps/sbrowser/feature/bookmarks/di/BookmarkRepositoryModule.kt
  27. 41 0
      app/src/main/java/com/codeskraps/sbrowser/feature/bookmarks/domain/model/Bookmark.kt
  28. 14 0
      app/src/main/java/com/codeskraps/sbrowser/feature/bookmarks/domain/repository/LocalBookmarkRepository.kt
  29. 98 0
      app/src/main/java/com/codeskraps/sbrowser/feature/bookmarks/presentation/BookmarkViewModel.kt
  30. 65 0
      app/src/main/java/com/codeskraps/sbrowser/feature/bookmarks/presentation/components/BookmarkEditDialog.kt
  31. 91 0
      app/src/main/java/com/codeskraps/sbrowser/feature/bookmarks/presentation/components/BookmarkItem.kt
  32. 97 0
      app/src/main/java/com/codeskraps/sbrowser/feature/bookmarks/presentation/components/BookmarksScreen.kt
  33. 5 0
      app/src/main/java/com/codeskraps/sbrowser/feature/bookmarks/presentation/mvi/BookmarkAction.kt
  34. 11 0
      app/src/main/java/com/codeskraps/sbrowser/feature/bookmarks/presentation/mvi/BookmarkEvent.kt
  35. 32 0
      app/src/main/java/com/codeskraps/sbrowser/feature/bookmarks/presentation/mvi/BookmarkState.kt
  36. 73 0
      app/src/main/java/com/codeskraps/sbrowser/feature/settings/SettingsViewModel.kt
  37. 22 0
      app/src/main/java/com/codeskraps/sbrowser/feature/settings/components/CategoryPreference.kt
  38. 59 0
      app/src/main/java/com/codeskraps/sbrowser/feature/settings/components/CheckPreference.kt
  39. 94 0
      app/src/main/java/com/codeskraps/sbrowser/feature/settings/components/ListPreference.kt
  40. 90 0
      app/src/main/java/com/codeskraps/sbrowser/feature/settings/components/Preference.kt
  41. 77 0
      app/src/main/java/com/codeskraps/sbrowser/feature/settings/components/SettingsScreen.kt
  42. 20 0
      app/src/main/java/com/codeskraps/sbrowser/feature/settings/components/SpacerPreference.kt
  43. 3 0
      app/src/main/java/com/codeskraps/sbrowser/feature/settings/mvi/SettingsAction.kt
  44. 11 0
      app/src/main/java/com/codeskraps/sbrowser/feature/settings/mvi/SettingsEvent.kt
  45. 20 0
      app/src/main/java/com/codeskraps/sbrowser/feature/settings/mvi/SettingsState.kt
  46. 33 0
      app/src/main/java/com/codeskraps/sbrowser/feature/video/VideoViewModel.kt
  47. 55 0
      app/src/main/java/com/codeskraps/sbrowser/feature/video/components/VideoScreen.kt
  48. 3 0
      app/src/main/java/com/codeskraps/sbrowser/feature/video/mvi/VideoAction.kt
  49. 5 0
      app/src/main/java/com/codeskraps/sbrowser/feature/video/mvi/VideoEvent.kt
  50. 11 0
      app/src/main/java/com/codeskraps/sbrowser/feature/video/mvi/VideoState.kt
  51. 127 0
      app/src/main/java/com/codeskraps/sbrowser/feature/webview/MediaWebViewModel.kt
  52. 252 0
      app/src/main/java/com/codeskraps/sbrowser/feature/webview/components/WebViewButtons.kt
  53. 21 0
      app/src/main/java/com/codeskraps/sbrowser/feature/webview/components/WebViewProgressIndicator.kt
  54. 167 0
      app/src/main/java/com/codeskraps/sbrowser/feature/webview/components/WebViewScreen.kt
  55. 94 0
      app/src/main/java/com/codeskraps/sbrowser/feature/webview/media/HandleVideo.kt
  56. 15 0
      app/src/main/java/com/codeskraps/sbrowser/feature/webview/media/MediaWebChromeClient.kt
  57. 148 0
      app/src/main/java/com/codeskraps/sbrowser/feature/webview/media/MediaWebView.kt
  58. 56 0
      app/src/main/java/com/codeskraps/sbrowser/feature/webview/media/MediaWebViewClient.kt
  59. 8 0
      app/src/main/java/com/codeskraps/sbrowser/feature/webview/mvi/MediaWebViewAction.kt
  60. 16 0
      app/src/main/java/com/codeskraps/sbrowser/feature/webview/mvi/MediaWebViewEvent.kt
  61. 19 0
      app/src/main/java/com/codeskraps/sbrowser/feature/webview/mvi/MediaWebViewState.kt
  62. 22 0
      app/src/main/java/com/codeskraps/sbrowser/navigation/Screen.kt
  63. 15 0
      app/src/main/java/com/codeskraps/sbrowser/ui/theme/Color.kt
  64. 60 0
      app/src/main/java/com/codeskraps/sbrowser/ui/theme/Theme.kt
  65. 34 0
      app/src/main/java/com/codeskraps/sbrowser/ui/theme/Type.kt
  66. 19 0
      app/src/main/java/com/codeskraps/sbrowser/util/BackgroundStatus.kt
  67. 11 0
      app/src/main/java/com/codeskraps/sbrowser/util/Constants.kt
  68. 6 0
      app/src/main/java/com/codeskraps/sbrowser/util/Resource.kt
  69. 68 0
      app/src/main/java/com/codeskraps/sbrowser/util/StateReducerFlow.kt
  70. 22 0
      app/src/main/java/com/codeskraps/sbrowser/util/components/ObserveAsEvent.kt
  71. BIN
      app/src/main/res/drawable-night/ic_notification.png
  72. 10 0
      app/src/main/res/drawable/ic_home.xml
  73. 170 0
      app/src/main/res/drawable/ic_launcher_background.xml
  74. 30 0
      app/src/main/res/drawable/ic_launcher_foreground.xml
  75. BIN
      app/src/main/res/drawable/ic_notification.png
  76. 10 0
      app/src/main/res/drawable/ic_refresh.xml
  77. 5 0
      app/src/main/res/drawable/ic_web.xml
  78. 6 0
      app/src/main/res/mipmap-anydpi/ic_launcher.xml
  79. 6 0
      app/src/main/res/mipmap-anydpi/ic_launcher_round.xml
  80. BIN
      app/src/main/res/mipmap-hdpi/ic_launcher.webp
  81. BIN
      app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
  82. BIN
      app/src/main/res/mipmap-mdpi/ic_launcher.webp
  83. BIN
      app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
  84. BIN
      app/src/main/res/mipmap-xhdpi/ic_launcher.webp
  85. BIN
      app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
  86. BIN
      app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
  87. BIN
      app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
  88. BIN
      app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
  89. BIN
      app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
  90. 5 0
      app/src/main/res/values-night/colors.xml
  91. 5 0
      app/src/main/res/values/colors.xml
  92. 3 0
      app/src/main/res/values/strings.xml
  93. 7 0
      app/src/main/res/values/themes.xml
  94. 13 0
      app/src/main/res/xml/backup_rules.xml
  95. 19 0
      app/src/main/res/xml/data_extraction_rules.xml
  96. 17 0
      app/src/test/java/com/codeskraps/sbrowser/ExampleUnitTest.kt
  97. 14 0
      build.gradle.kts
  98. 23 0
      gradle.properties
  99. 55 0
      gradle/libs.versions.toml
  100. BIN
      gradle/wrapper/gradle-wrapper.jar

+ 15 - 0
.gitignore

@@ -0,0 +1,15 @@
+*.iml
+.gradle
+/local.properties
+/.idea/caches
+/.idea/libraries
+/.idea/modules.xml
+/.idea/workspace.xml
+/.idea/navEditor.xml
+/.idea/assetWizardSettings.xml
+.DS_Store
+/build
+/captures
+.externalNativeBuild
+.cxx
+local.properties

+ 3 - 0
.idea/.gitignore

@@ -0,0 +1,3 @@
+# Default ignored files
+/shelf/
+/workspace.xml

+ 6 - 0
.idea/compiler.xml

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

+ 10 - 0
.idea/deploymentTargetDropDown.xml

@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project version="4">
+  <component name="deploymentTargetDropDown">
+    <value>
+      <entry key="app">
+        <State />
+      </entry>
+    </value>
+  </component>
+</project>

+ 19 - 0
.idea/gradle.xml

@@ -0,0 +1,19 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project version="4">
+  <component name="GradleMigrationSettings" migrationVersion="1" />
+  <component name="GradleSettings">
+    <option name="linkedExternalProjectsSettings">
+      <GradleProjectSettings>
+        <option name="externalProjectPath" value="$PROJECT_DIR$" />
+        <option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
+        <option name="modules">
+          <set>
+            <option value="$PROJECT_DIR$" />
+            <option value="$PROJECT_DIR$/app" />
+          </set>
+        </option>
+        <option name="resolveExternalAnnotations" value="false" />
+      </GradleProjectSettings>
+    </option>
+  </component>
+</project>

+ 32 - 0
.idea/inspectionProfiles/Project_Default.xml

@@ -0,0 +1,32 @@
+<component name="InspectionProjectProfileManager">
+  <profile version="1.0">
+    <option name="myName" value="Project Default" />
+    <inspection_tool class="PreviewAnnotationInFunctionWithParameters" enabled="true" level="ERROR" enabled_by_default="true">
+      <option name="composableFile" value="true" />
+    </inspection_tool>
+    <inspection_tool class="PreviewApiLevelMustBeValid" enabled="true" level="ERROR" enabled_by_default="true">
+      <option name="composableFile" value="true" />
+    </inspection_tool>
+    <inspection_tool class="PreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
+      <option name="composableFile" value="true" />
+    </inspection_tool>
+    <inspection_tool class="PreviewFontScaleMustBeGreaterThanZero" enabled="true" level="ERROR" enabled_by_default="true">
+      <option name="composableFile" value="true" />
+    </inspection_tool>
+    <inspection_tool class="PreviewMultipleParameterProviders" enabled="true" level="ERROR" enabled_by_default="true">
+      <option name="composableFile" value="true" />
+    </inspection_tool>
+    <inspection_tool class="PreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
+      <option name="composableFile" value="true" />
+    </inspection_tool>
+    <inspection_tool class="PreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
+      <option name="composableFile" value="true" />
+    </inspection_tool>
+    <inspection_tool class="PreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
+      <option name="composableFile" value="true" />
+    </inspection_tool>
+    <inspection_tool class="PreviewPickerAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
+      <option name="composableFile" value="true" />
+    </inspection_tool>
+  </profile>
+</component>

+ 6 - 0
.idea/kotlinc.xml

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

+ 10 - 0
.idea/migrations.xml

@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project version="4">
+  <component name="ProjectMigrations">
+    <option name="MigrateToGradleLocalJavaHome">
+      <set>
+        <option value="$PROJECT_DIR$" />
+      </set>
+    </option>
+  </component>
+</project>

+ 9 - 0
.idea/misc.xml

@@ -0,0 +1,9 @@
+<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">
+    <output url="file://$PROJECT_DIR$/build/classes" />
+  </component>
+  <component name="ProjectType">
+    <option name="id" value="Android" />
+  </component>
+</project>

+ 1 - 0
app/.gitignore

@@ -0,0 +1 @@
+/build

+ 91 - 0
app/build.gradle.kts

@@ -0,0 +1,91 @@
+@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)
+}
+
+android {
+    namespace = "com.codeskraps.sbrowser"
+    compileSdk = 34
+
+    defaultConfig {
+        applicationId = "com.codeskraps.sbrowser_d"
+        minSdk = 26
+        targetSdk = 34
+        versionCode = 1
+        versionName = "1.0"
+
+        testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+        vectorDrawables {
+            useSupportLibrary = true
+        }
+    }
+
+    buildTypes {
+        release {
+            isMinifyEnabled = false
+            proguardFiles(
+                getDefaultProguardFile("proguard-android-optimize.txt"),
+                "proguard-rules.pro"
+            )
+        }
+    }
+    compileOptions {
+        sourceCompatibility = JavaVersion.VERSION_17
+        targetCompatibility = JavaVersion.VERSION_17
+    }
+    kotlinOptions {
+        jvmTarget = "17"
+    }
+    buildFeatures {
+        compose = true
+    }
+    composeOptions {
+        kotlinCompilerExtensionVersion = "1.5.7"
+    }
+    packaging {
+        resources {
+            excludes += "/META-INF/{AL2.0,LGPL2.1}"
+        }
+    }
+}
+
+dependencies {
+
+    implementation(libs.core.ktx)
+    implementation(libs.lifecycle.runtime.ktx)
+    implementation(libs.lifecycle.runtime.compose)
+    implementation(libs.activity.compose)
+    implementation(platform(libs.compose.bom))
+    implementation(libs.ui)
+    implementation(libs.ui.graphics)
+    implementation(libs.ui.tooling.preview)
+    implementation(libs.material3)
+
+    //Dagger - Hilt
+    implementation(libs.hilt.android)
+    implementation(libs.hilt.navigation.compose)
+    ksp(libs.hilt.android.compiler)
+
+    // Room
+    implementation(libs.room.runtime)
+    implementation(libs.room.ktx)
+    annotationProcessor(libs.room.compiler)
+    ksp(libs.room.compiler)
+
+    implementation(libs.jsoup)
+
+    implementation(libs.media3.exoplayer)
+    //implementation(libs.media3.session)
+    implementation(libs.media3.ui)
+
+    testImplementation(libs.junit)
+    androidTestImplementation(libs.androidx.test.ext.junit)
+    androidTestImplementation(libs.espresso.core)
+    androidTestImplementation(platform(libs.compose.bom))
+    androidTestImplementation(libs.ui.test.junit4)
+    debugImplementation(libs.ui.tooling)
+    debugImplementation(libs.ui.test.manifest)
+}

+ 21 - 0
app/proguard-rules.pro

@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+#   http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+#   public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile

+ 24 - 0
app/src/androidTest/java/com/codeskraps/sbrowser/ExampleInstrumentedTest.kt

@@ -0,0 +1,24 @@
+package com.codeskraps.sbrowser
+
+import androidx.test.platform.app.InstrumentationRegistry
+import androidx.test.ext.junit.runners.AndroidJUnit4
+
+import org.junit.Test
+import org.junit.runner.RunWith
+
+import org.junit.Assert.*
+
+/**
+ * Instrumented test, which will execute on an Android device.
+ *
+ * See [testing documentation](http://d.android.com/tools/testing).
+ */
+@RunWith(AndroidJUnit4::class)
+class ExampleInstrumentedTest {
+    @Test
+    fun useAppContext() {
+        // Context of the app under test.
+        val appContext = InstrumentationRegistry.getInstrumentation().targetContext
+        assertEquals("com.codeskraps.sbrowser", appContext.packageName)
+    }
+}

+ 39 - 0
app/src/main/AndroidManifest.xml

@@ -0,0 +1,39 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:tools="http://schemas.android.com/tools">
+
+    <uses-permission android:name="android.permission.INTERNET" />
+    <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
+    <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
+    <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
+
+    <application
+        android:name=".SBrowserApp"
+        android:allowBackup="true"
+        android:dataExtractionRules="@xml/data_extraction_rules"
+        android:fullBackupContent="@xml/backup_rules"
+        android:icon="@mipmap/ic_launcher"
+        android:label="@string/app_name"
+        android:roundIcon="@mipmap/ic_launcher_round"
+        android:supportsRtl="true"
+        android:theme="@style/Theme.SBrowser"
+        tools:targetApi="31">
+        <activity
+            android:name=".MainActivity"
+            android:exported="true"
+            android:label="@string/app_name"
+            android:theme="@style/Theme.SBrowser">
+            <intent-filter>
+                <action android:name="android.intent.action.MAIN" />
+
+                <category android:name="android.intent.category.LAUNCHER" />
+            </intent-filter>
+        </activity>
+        <service
+            android:name=".ForegroundService"
+            android:enabled="true"
+            android:exported="false"
+            android:foregroundServiceType="mediaPlayback" />
+    </application>
+
+</manifest>

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

@@ -0,0 +1,141 @@
+package com.codeskraps.sbrowser
+
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.app.Service
+import android.content.Intent
+import android.os.IBinder
+import android.util.Log
+import androidx.core.app.NotificationCompat
+import com.codeskraps.sbrowser.feature.webview.media.MediaWebView
+import com.codeskraps.sbrowser.util.BackgroundStatus
+import com.codeskraps.sbrowser.util.Constants
+import dagger.hilt.android.AndroidEntryPoint
+import javax.inject.Inject
+
+@AndroidEntryPoint
+class ForegroundService : Service() {
+    companion object {
+        private val TAG = ForegroundService::class.java.simpleName
+        private const val NOTIF_ID = 1
+        private const val CHANNEL_ID = "ForegroundServiceChannel"
+        private const val DELETE_EXTRA = "deleteExtra"
+        private const val HOME_EXTRA = "homeExtra"
+        private const val REFRESH_EXTRA = "refreshExtra"
+    }
+
+    @Inject
+    lateinit var backgroundStatus: BackgroundStatus
+
+    @Inject
+    lateinit var mediaWebView: MediaWebView
+
+    override fun onBind(intent: Intent?): IBinder? = null
+
+    override fun onCreate() {
+        super.onCreate()
+        mediaWebView.setUrlListener { url ->
+            (getSystemService(NOTIFICATION_SERVICE) as NotificationManager).run {
+                notify(NOTIF_ID, createNotification(url))
+            }
+        }
+    }
+
+    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+        Log.v(TAG, "onStartCommand")
+
+        var url = ""
+
+        intent?.extras?.let {
+            if (it.getBoolean(DELETE_EXTRA, false)) {
+                stopSelf()
+                return START_NOT_STICKY
+
+            } else if (it.getBoolean(HOME_EXTRA, false)) {
+                mediaWebView.loadUrl(Constants.home)
+                return START_NOT_STICKY
+
+            } else if (it.getBoolean(REFRESH_EXTRA, false)) {
+                mediaWebView.reload()
+                return START_NOT_STICKY
+
+            } else {
+                url = it.getString(Constants.inputExtra) ?: url
+            }
+        }
+
+        createNotificationChannel()
+        startForeground(NOTIF_ID, createNotification(url))
+        backgroundStatus.setValue(true)
+
+        return START_NOT_STICKY
+    }
+
+    override fun onDestroy() {
+        super.onDestroy()
+        backgroundStatus.setValue(false)
+        mediaWebView.setUrlListener(null)
+    }
+
+    private fun createNotificationChannel() {
+        val serviceChannel = NotificationChannel(
+            CHANNEL_ID,
+            "Foreground MediaWebView Channel",
+            NotificationManager.IMPORTANCE_DEFAULT
+        )
+        getSystemService(NotificationManager::class.java).run {
+            createNotificationChannel(serviceChannel)
+        }
+    }
+
+    private fun createNotification(contentText: String): Notification =
+        NotificationCompat.Builder(this, CHANNEL_ID)
+            .setContentTitle(getString(R.string.app_name))
+            .setContentText(contentText)
+            .setSmallIcon(R.drawable.ic_notification)
+            .setContentIntent(contentPendingIntent())
+            .setDeleteIntent(deletePendingIntent())
+            .setPriority(NotificationCompat.PRIORITY_DEFAULT)
+            .setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
+            .addAction(R.drawable.ic_home, "Home", homePendingIntent())
+            .addAction(R.drawable.ic_refresh, "Refresh", refreshPendingIntent())
+            .build()
+
+    private fun contentPendingIntent(): PendingIntent = PendingIntent.getActivity(
+        this,
+        2,
+        Intent(this, MainActivity::class.java).apply {
+            flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
+        },
+        PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_IMMUTABLE
+    )
+
+    private fun deletePendingIntent(): PendingIntent = PendingIntent.getService(
+        this,
+        3,
+        Intent(this, ForegroundService::class.java).apply {
+            putExtra(DELETE_EXTRA, true)
+        },
+        PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_IMMUTABLE
+    )
+
+    private fun homePendingIntent(): PendingIntent = PendingIntent.getService(
+        this,
+        4,
+        Intent(this, ForegroundService::class.java).apply {
+            putExtra(HOME_EXTRA, true)
+        },
+        PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_IMMUTABLE
+    )
+
+    private fun refreshPendingIntent(): PendingIntent = PendingIntent.getService(
+        this,
+        5,
+        Intent(this, ForegroundService::class.java).apply {
+            putExtra(REFRESH_EXTRA, true)
+        },
+        PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_IMMUTABLE
+    )
+}

+ 104 - 0
app/src/main/java/com/codeskraps/sbrowser/MainActivity.kt

@@ -0,0 +1,104 @@
+package com.codeskraps.sbrowser
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+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.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.navigation.NavType
+import androidx.navigation.compose.NavHost
+import androidx.navigation.compose.composable
+import androidx.navigation.compose.rememberNavController
+import androidx.navigation.navArgument
+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.video.VideoViewModel
+import com.codeskraps.sbrowser.feature.video.components.VideoScreen
+import com.codeskraps.sbrowser.feature.webview.MediaWebViewModel
+import com.codeskraps.sbrowser.feature.webview.components.WebViewScreen
+import com.codeskraps.sbrowser.navigation.Screen
+import com.codeskraps.sbrowser.ui.theme.SBrowserTheme
+import dagger.hilt.android.AndroidEntryPoint
+
+@AndroidEntryPoint
+class MainActivity : ComponentActivity() {
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+        setContent {
+            SBrowserTheme {
+                Surface(
+                    modifier = Modifier.fillMaxSize(),
+                    color = MaterialTheme.colorScheme.background
+                ) {
+                    val navController = rememberNavController()
+
+                    NavHost(
+                        navController = navController,
+                        startDestination = Screen.WebView.route
+                    ) {
+                        composable(
+                            route = Screen.WebView.route,
+                            arguments = listOf(navArgument("url") { type = NavType.StringType })
+                        ) {
+                            val viewModel = hiltViewModel<MediaWebViewModel>()
+                            val state by viewModel.state.collectAsStateWithLifecycle()
+
+                            WebViewScreen(
+                                mediaWebView = viewModel.mediaWebView,
+                                state = state,
+                                handleEvent = viewModel.state::handleEvent,
+                                action = viewModel.action
+                            ) { route ->
+                                navController.navigate(route)
+                            }
+                        }
+                        composable(
+                            route = Screen.Bookmarks.route
+                        ) {
+                            val viewModel = hiltViewModel<BookmarkViewModel>()
+                            val state by viewModel.state.collectAsStateWithLifecycle()
+
+                            BookmarksScreen(
+                                state = state,
+                                handleEvent = viewModel.state::handleEvent,
+                                action = viewModel.action
+                            ) { route ->
+                                navController.navigate(route)
+                            }
+                        }
+                        composable(
+                            route = Screen.Settings.route
+                        ) {
+                            val viewModel = hiltViewModel<SettingsViewModel>()
+                            val state by viewModel.state.collectAsStateWithLifecycle()
+
+                            SettingsScreen(
+                                state = state,
+                                handleEvent = viewModel.state::handleEvent
+                            )
+                        }
+                        composable(
+                            route = Screen.Video.route,
+                            arguments = listOf(navArgument("url") { type = NavType.StringType })
+                        ) {
+                            val viewModel = hiltViewModel<VideoViewModel>()
+                            val state by viewModel.state.collectAsStateWithLifecycle()
+
+                            VideoScreen(
+                                state = state,
+                                handleEvent = viewModel.state::handleEvent
+                            )
+                        }
+                    }
+                }
+            }
+        }
+    }
+}

+ 64 - 0
app/src/main/java/com/codeskraps/sbrowser/MediaWebViewPreferences.kt

@@ -0,0 +1,64 @@
+package com.codeskraps.sbrowser
+
+import android.content.Context
+import android.webkit.WebSettings.PluginState
+import com.codeskraps.sbrowser.util.Constants
+import javax.inject.Inject
+
+class MediaWebViewPreferences @Inject constructor(
+    val context: Context
+) {
+    companion object {
+        private const val PREF_FILE = "pref_file"
+        private const val PREF_HOME = "pref_home"
+        private const val PREF_JAVASCRIPT = "pref_javascript"
+        private const val PREF_PLUGINS = "pref_plugins"
+        private const val PREF_USER_AGENT = "pref_user_agent"
+    }
+
+    private val prefs by lazy {
+        context.getSharedPreferences(PREF_FILE, Context.MODE_PRIVATE)
+    }
+
+    var homeUrl: String
+        get() = prefs.getString(PREF_HOME, Constants.home) ?: Constants.home
+        set(value) {
+            prefs.edit().putString(PREF_HOME, value).apply()
+        }
+
+    var javaScript: Boolean
+        get() = prefs.getBoolean(PREF_JAVASCRIPT, Constants.javaScript)
+        set(value) {
+            prefs.edit().putBoolean(PREF_JAVASCRIPT, value).apply()
+        }
+
+    var plugins: PluginState
+        get() {
+            val default = when (Constants.plugins) {
+                PluginState.ON -> "Always on"
+                PluginState.ON_DEMAND -> "On demand"
+                else -> "Off"
+            }
+
+            return when (prefs.getString(PREF_PLUGINS, default) ?: default) {
+                "Always on" -> PluginState.ON
+                "On demand" -> PluginState.ON_DEMAND
+                else -> PluginState.OFF
+            }
+        }
+        set(value) {
+            prefs.edit().putString(
+                PREF_PLUGINS, when (value) {
+                    PluginState.ON -> "Always on"
+                    PluginState.ON_DEMAND -> "On demand"
+                    else -> "Off"
+                }
+            ).apply()
+        }
+
+    var userAgent: String
+        get() = prefs.getString(PREF_USER_AGENT, Constants.userAgent) ?: Constants.userAgent
+        set(value) {
+            prefs.edit().putString(PREF_USER_AGENT, value).apply()
+        }
+}

+ 7 - 0
app/src/main/java/com/codeskraps/sbrowser/SBrowserApp.kt

@@ -0,0 +1,7 @@
+package com.codeskraps.sbrowser
+
+import android.app.Application
+import dagger.hilt.android.HiltAndroidApp
+
+@HiltAndroidApp
+class SBrowserApp : Application()

+ 39 - 0
app/src/main/java/com/codeskraps/sbrowser/di/AppModule.kt

@@ -0,0 +1,39 @@
+package com.codeskraps.sbrowser.di
+
+import android.app.Application
+import com.codeskraps.sbrowser.MediaWebViewPreferences
+import com.codeskraps.sbrowser.feature.webview.media.MediaWebView
+import com.codeskraps.sbrowser.util.BackgroundStatus
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+object AppModule {
+
+    @Provides
+    @Singleton
+    fun providesMediaWebView(
+        application: Application,
+        mediaWebViewPreferences: MediaWebViewPreferences,
+    ): MediaWebView {
+        return MediaWebView(application, mediaWebViewPreferences)
+    }
+
+    @Provides
+    @Singleton
+    fun providesBackgroundStatus(): BackgroundStatus {
+        return BackgroundStatus()
+    }
+
+    @Provides
+    @Singleton
+    fun providesMediaWebViewPreferences(
+        application: Application
+    ): MediaWebViewPreferences {
+        return MediaWebViewPreferences(application)
+    }
+}

+ 13 - 0
app/src/main/java/com/codeskraps/sbrowser/feature/bookmarks/data/local/BookmarkDB.kt

@@ -0,0 +1,13 @@
+package com.codeskraps.sbrowser.feature.bookmarks.data.local
+
+import androidx.room.Database
+import androidx.room.RoomDatabase
+
+@Database(
+    entities = [BookmarkEntity::class],
+    version = 1
+)
+abstract class BookmarkDB : RoomDatabase() {
+
+    abstract fun bookmarkDao(): BookmarkDao
+}

+ 20 - 0
app/src/main/java/com/codeskraps/sbrowser/feature/bookmarks/data/local/BookmarkDao.kt

@@ -0,0 +1,20 @@
+package com.codeskraps.sbrowser.feature.bookmarks.data.local
+
+import androidx.room.Dao
+import androidx.room.Delete
+import androidx.room.Query
+import androidx.room.Upsert
+import kotlinx.coroutines.flow.Flow
+
+@Dao
+interface BookmarkDao {
+
+    @Query("SELECT * FROM BookmarkEntity")
+    fun getAll(): Flow<List<BookmarkEntity>?>
+
+    @Upsert
+    suspend fun insert(bookmarkEntity: BookmarkEntity)
+
+    @Delete
+    suspend fun delete(bookmarkEntity: BookmarkEntity)
+}

+ 39 - 0
app/src/main/java/com/codeskraps/sbrowser/feature/bookmarks/data/local/BookmarkEntity.kt

@@ -0,0 +1,39 @@
+package com.codeskraps.sbrowser.feature.bookmarks.data.local
+
+import androidx.room.Entity
+import androidx.room.PrimaryKey
+
+@Entity
+data class BookmarkEntity(
+    @PrimaryKey(autoGenerate = true)
+    val uid: Int,
+    val title: String,
+    val url: String,
+    val image: ByteArray?
+) {
+    override fun equals(other: Any?): Boolean {
+        if (this === other) return true
+        if (javaClass != other?.javaClass) return false
+
+        other as BookmarkEntity
+
+        if (uid != other.uid) return false
+        if (title != other.title) return false
+        if (url != other.url) return false
+        if (image != null) {
+            if (other.image == null) return false
+            if (!image.contentEquals(other.image)) return false
+        } else if (other.image != null) return false
+
+        return true
+    }
+
+    override fun hashCode(): Int {
+        var result = uid
+        result = 31 * result + title.hashCode()
+        result = 31 * result + url.hashCode()
+        result = 31 * result + (image?.contentHashCode() ?: 0)
+        return result
+    }
+
+}

+ 22 - 0
app/src/main/java/com/codeskraps/sbrowser/feature/bookmarks/data/mappers/BookmarkMapper.kt

@@ -0,0 +1,22 @@
+package com.codeskraps.sbrowser.feature.bookmarks.data.mappers
+
+import com.codeskraps.sbrowser.feature.bookmarks.data.local.BookmarkEntity
+import com.codeskraps.sbrowser.feature.bookmarks.domain.model.Bookmark
+
+fun Bookmark.toBookmarkEntity(): BookmarkEntity {
+    return BookmarkEntity(
+        uid = uid,
+        title = title,
+        url = url,
+        image = image
+    )
+}
+
+fun BookmarkEntity.toBookmark(): Bookmark {
+    return Bookmark(
+        uid = uid,
+        title = title,
+        url = url,
+        image = image
+    )
+}

+ 50 - 0
app/src/main/java/com/codeskraps/sbrowser/feature/bookmarks/data/repository/LocalBookmarkRepositoryImpl.kt

@@ -0,0 +1,50 @@
+package com.codeskraps.sbrowser.feature.bookmarks.data.repository
+
+import com.codeskraps.sbrowser.feature.bookmarks.data.local.BookmarkDao
+import com.codeskraps.sbrowser.feature.bookmarks.data.mappers.toBookmark
+import com.codeskraps.sbrowser.feature.bookmarks.data.mappers.toBookmarkEntity
+import com.codeskraps.sbrowser.feature.bookmarks.domain.model.Bookmark
+import com.codeskraps.sbrowser.feature.bookmarks.domain.repository.LocalBookmarkRepository
+import com.codeskraps.sbrowser.util.Resource
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.flow
+import javax.inject.Inject
+
+class LocalBookmarkRepositoryImpl @Inject constructor(
+    private val bookmarkDao: BookmarkDao
+) : LocalBookmarkRepository {
+
+    override fun getAllBookmarks(): Flow<Resource<List<Bookmark>>> {
+        return flow {
+            runCatching {
+                bookmarkDao.getAll().collect { items ->
+                    emit(Resource.Success(items?.map { it.toBookmark() } ?: emptyList()))
+                }
+            }.getOrElse {
+                emit(Resource.Error("DB ERROR !!!"))
+            }
+        }
+    }
+
+    override suspend fun saveBookmark(bookmark: Bookmark): Resource<Unit> {
+        return runCatching {
+            bookmarkDao.insert(bookmark.toBookmarkEntity())
+            Resource.Success(Unit)
+        }.getOrElse {
+            Resource.Error(
+                message = "Issue saving bookmark into DB !!!"
+            )
+        }
+    }
+
+    override suspend fun deleteBookmark(bookmark: Bookmark): Resource<Unit> {
+        return runCatching {
+            bookmarkDao.delete(bookmark.toBookmarkEntity())
+            Resource.Success(Unit)
+        }.getOrElse {
+            Resource.Error(
+                message = "Issue deleting bookmark from DB !!!"
+            )
+        }
+    }
+}

+ 29 - 0
app/src/main/java/com/codeskraps/sbrowser/feature/bookmarks/di/BookmarkFeatureModule.kt

@@ -0,0 +1,29 @@
+package com.codeskraps.sbrowser.feature.bookmarks.di
+
+import android.app.Application
+import androidx.room.Room
+import com.codeskraps.sbrowser.feature.bookmarks.data.local.BookmarkDB
+import com.codeskraps.sbrowser.feature.bookmarks.data.local.BookmarkDao
+import dagger.Binds
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+object BookmarkFeatureModule {
+
+    @Provides
+    @Singleton
+    fun providesBookmarkDao(
+        application: Application
+    ): BookmarkDao {
+        return Room.databaseBuilder(
+            context = application,
+            klass = BookmarkDB::class.java,
+            name = "bookmarks.db"
+        ).build().bookmarkDao()
+    }
+}

+ 18 - 0
app/src/main/java/com/codeskraps/sbrowser/feature/bookmarks/di/BookmarkRepositoryModule.kt

@@ -0,0 +1,18 @@
+package com.codeskraps.sbrowser.feature.bookmarks.di
+
+import com.codeskraps.sbrowser.feature.bookmarks.data.repository.LocalBookmarkRepositoryImpl
+import com.codeskraps.sbrowser.feature.bookmarks.domain.repository.LocalBookmarkRepository
+import dagger.Binds
+import dagger.Module
+import dagger.hilt.InstallIn
+import dagger.hilt.android.components.ViewModelComponent
+
+@Module
+@InstallIn(ViewModelComponent::class)
+interface BookmarkRepositoryModule {
+
+    @Binds
+    fun bindsLocalBookmarkRepository(
+        localBookmarkRepositoryImpl: LocalBookmarkRepositoryImpl
+    ): LocalBookmarkRepository
+}

+ 41 - 0
app/src/main/java/com/codeskraps/sbrowser/feature/bookmarks/domain/model/Bookmark.kt

@@ -0,0 +1,41 @@
+package com.codeskraps.sbrowser.feature.bookmarks.domain.model
+
+data class Bookmark(
+    val uid: Int,
+    val title: String,
+    val url: String,
+    val image: ByteArray?
+) {
+    fun bookmarkUrl(): String {
+        return if (url.startsWith("https://")) {
+            url.substring("https://".length)
+        } else if (url.startsWith("http://")) {
+            url.substring("http://".length)
+        } else url
+    }
+
+    override fun equals(other: Any?): Boolean {
+        if (this === other) return true
+        if (javaClass != other?.javaClass) return false
+
+        other as Bookmark
+
+        if (uid != other.uid) return false
+        if (title != other.title) return false
+        if (url != other.url) return false
+        if (image != null) {
+            if (other.image == null) return false
+            if (!image.contentEquals(other.image)) return false
+        } else if (other.image != null) return false
+
+        return true
+    }
+
+    override fun hashCode(): Int {
+        var result = uid
+        result = 31 * result + title.hashCode()
+        result = 31 * result + url.hashCode()
+        result = 31 * result + (image?.contentHashCode() ?: 0)
+        return result
+    }
+}

+ 14 - 0
app/src/main/java/com/codeskraps/sbrowser/feature/bookmarks/domain/repository/LocalBookmarkRepository.kt

@@ -0,0 +1,14 @@
+package com.codeskraps.sbrowser.feature.bookmarks.domain.repository
+
+import com.codeskraps.sbrowser.feature.bookmarks.domain.model.Bookmark
+import com.codeskraps.sbrowser.util.Resource
+import kotlinx.coroutines.flow.Flow
+
+interface LocalBookmarkRepository {
+
+    fun getAllBookmarks(): Flow<Resource<List<Bookmark>>>
+
+    suspend fun saveBookmark(bookmark: Bookmark): Resource<Unit>
+
+    suspend fun deleteBookmark(bookmark: Bookmark): Resource<Unit>
+}

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

@@ -0,0 +1,98 @@
+package com.codeskraps.sbrowser.feature.bookmarks.presentation
+
+import androidx.lifecycle.viewModelScope
+import com.codeskraps.sbrowser.feature.bookmarks.domain.model.Bookmark
+import com.codeskraps.sbrowser.feature.bookmarks.domain.repository.LocalBookmarkRepository
+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.util.Resource
+import com.codeskraps.sbrowser.util.StateReducerViewModel
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+@HiltViewModel
+class BookmarkViewModel @Inject constructor(
+    private val webView: MediaWebView,
+    private val localBookmarkRepository: LocalBookmarkRepository
+) : StateReducerViewModel<BookmarkState, BookmarkEvent, BookmarkAction>() {
+
+    override fun initState(): BookmarkState = BookmarkState.initial
+
+    init {
+        viewModelScope.launch(Dispatchers.IO) {
+            localBookmarkRepository.getAllBookmarks().collect { result ->
+                state.handleEvent(BookmarkEvent.Loaded(result))
+            }
+        }
+    }
+
+    override fun reduceState(currentState: BookmarkState, event: BookmarkEvent): BookmarkState {
+        return when (event) {
+            is BookmarkEvent.Loaded -> onLoaded(currentState, event.bookmarks)
+            is BookmarkEvent.Add -> onAdd(currentState)
+            is BookmarkEvent.Edit -> onEdit(currentState, event.bookmark)
+            is BookmarkEvent.Delete -> onDelete(currentState, event.bookmark)
+        }
+    }
+
+    private fun onLoaded(
+        currentState: BookmarkState,
+        result: Resource<List<Bookmark>>
+    ): BookmarkState {
+        return when (result) {
+            is Resource.Error -> currentState.setError(result.message)
+            is Resource.Success -> currentState.setBookmarks(result.data)
+        }
+    }
+
+    private fun onAdd(currentState: BookmarkState): BookmarkState {
+        val title = webView.title
+        val url = webView.url
+
+        if (!url.isNullOrBlank() && !title.isNullOrBlank()) {
+            saveBookmark(
+                bookmark = Bookmark(
+                    uid = 0,
+                    title = title,
+                    url = url,
+                    image = webView.capturePicture()
+                )
+            )
+        }
+        return currentState
+    }
+
+    private fun onEdit(currentState: BookmarkState, bookmark: Bookmark): BookmarkState {
+        saveBookmark(bookmark = bookmark)
+        return currentState
+    }
+
+    private fun saveBookmark(bookmark: Bookmark) {
+        viewModelScope.launch(Dispatchers.IO) {
+            when (val result = localBookmarkRepository.saveBookmark(bookmark)) {
+                is Resource.Error -> {
+                    actionChannel.send(BookmarkAction.Toast(result.message))
+                }
+
+                is Resource.Success -> {}
+            }
+        }
+    }
+
+    private fun onDelete(currentState: BookmarkState, bookmark: Bookmark): BookmarkState {
+        viewModelScope.launch(Dispatchers.IO) {
+            when (val result = localBookmarkRepository.deleteBookmark(bookmark)) {
+                is Resource.Error -> {
+                    actionChannel.send(BookmarkAction.Toast(result.message))
+                }
+
+                is Resource.Success -> {}
+            }
+        }
+        return currentState
+    }
+}

+ 65 - 0
app/src/main/java/com/codeskraps/sbrowser/feature/bookmarks/presentation/components/BookmarkEditDialog.kt

@@ -0,0 +1,65 @@
+package com.codeskraps.sbrowser.feature.bookmarks.presentation.components
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.height
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import com.codeskraps.sbrowser.feature.bookmarks.domain.model.Bookmark
+import com.codeskraps.sbrowser.feature.bookmarks.presentation.mvi.BookmarkEvent
+
+@Composable
+fun BookmarkEditDialog(
+    item: Bookmark,
+    dialogTitle: String,
+    handleEvent: (BookmarkEvent) -> Unit,
+    onDismissRequest: () -> Unit
+) {
+    var title by remember { mutableStateOf(item.title) }
+    var url by remember { mutableStateOf(item.url) }
+
+    AlertDialog(
+        onDismissRequest = { onDismissRequest() },
+        confirmButton = {
+            TextButton(onClick = {
+                onDismissRequest()
+                handleEvent(
+                    BookmarkEvent.Edit(
+                        item.copy(
+                            title = title,
+                            url = url
+                        )
+                    )
+                )
+            }) {
+                Text(text = "Save")
+            }
+        },
+        dismissButton = {
+            TextButton(onClick = { onDismissRequest() }) {
+                Text(text = "Cancel")
+            }
+        },
+        title = { Text(text = dialogTitle) },
+        text = {
+            Column {
+                Text(text = "Name:")
+                Spacer(modifier = Modifier.height(10.dp))
+                OutlinedTextField(value = title, onValueChange = { title = it })
+                Spacer(modifier = Modifier.height(20.dp))
+                Text(text = "Url:")
+                Spacer(modifier = Modifier.height(10.dp))
+                OutlinedTextField(value = url, onValueChange = { url = it })
+            }
+        }
+    )
+}

+ 91 - 0
app/src/main/java/com/codeskraps/sbrowser/feature/bookmarks/presentation/components/BookmarkItem.kt

@@ -0,0 +1,91 @@
+package com.codeskraps.sbrowser.feature.bookmarks.presentation.components
+
+import android.graphics.BitmapFactory
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.combinedClickable
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Star
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+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.asImageBitmap
+import androidx.compose.ui.unit.dp
+import com.codeskraps.sbrowser.feature.bookmarks.domain.model.Bookmark
+import com.codeskraps.sbrowser.feature.bookmarks.presentation.mvi.BookmarkEvent
+import com.codeskraps.sbrowser.navigation.Screen
+
+@OptIn(ExperimentalFoundationApi::class)
+@Composable
+fun BookmarkItem(
+    item: Bookmark,
+    handleEvent: (BookmarkEvent) -> Unit,
+    navRoute: (String) -> Unit
+) {
+    var expanded by remember { mutableStateOf(false) }
+    val editDialog = remember { mutableStateOf(false) }
+
+    if (editDialog.value) {
+        BookmarkEditDialog(
+            item = item,
+            dialogTitle = "Edit Bookmark",
+            handleEvent = handleEvent
+        ) {
+            editDialog.value = false
+        }
+    }
+
+    Column(
+        modifier = Modifier
+            .width(100.dp)
+            .padding(10.dp)
+            .combinedClickable(
+                onClick = { navRoute(Screen.WebView.createRoute(item.url)) },
+                onLongClick = { expanded = true }
+            )
+    ) {
+        item.image?.let {
+            val bm = BitmapFactory.decodeByteArray(it, 0, it.size)
+            Image(
+                bitmap = bm.asImageBitmap(),
+                contentDescription = "Thumbnail"
+            )
+        } ?: run {
+            Image(
+                imageVector = Icons.Default.Star,
+                contentDescription = "Thumbnail"
+            )
+        }
+        Text(text = item.title, maxLines = 1)
+        Text(text = item.bookmarkUrl(), maxLines = 1)
+
+        DropdownMenu(
+            expanded = expanded,
+            onDismissRequest = { expanded = false }
+        ) {
+            DropdownMenuItem(
+                text = { Text("Edit Bookmark") },
+                onClick = {
+                    expanded = false
+                    editDialog.value = true
+                }
+            )
+            DropdownMenuItem(
+                text = { Text("Delete Bookmark") },
+                onClick = {
+                    expanded = false
+                    handleEvent(BookmarkEvent.Delete(item))
+                }
+            )
+        }
+    }
+}

+ 97 - 0
app/src/main/java/com/codeskraps/sbrowser/feature/bookmarks/presentation/components/BookmarksScreen.kt

@@ -0,0 +1,97 @@
+package com.codeskraps.sbrowser.feature.bookmarks.presentation.components
+
+import android.widget.Toast
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.grid.GridCells
+import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
+import androidx.compose.foundation.lazy.grid.items
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Add
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+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.util.components.ObserveAsEvents
+import kotlinx.coroutines.flow.Flow
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun BookmarksScreen(
+    state: BookmarkState,
+    handleEvent: (BookmarkEvent) -> Unit,
+    action: Flow<BookmarkAction>,
+    navRoute: (String) -> Unit
+) {
+    val context = LocalContext.current
+    ObserveAsEvents(flow = action) { onAction ->
+        when (onAction) {
+            is BookmarkAction.Toast -> Toast.makeText(context, onAction.message, Toast.LENGTH_LONG)
+                .show()
+        }
+    }
+
+    Scaffold(
+        modifier = Modifier.fillMaxSize(),
+        topBar = {
+            TopAppBar(title = { Text(text = "Bookmarks") })
+        }
+    ) { paddingValues ->
+        if (state.isLoading) {
+            Box(modifier = Modifier.fillMaxSize()) {
+                CircularProgressIndicator(
+                    modifier = Modifier.align(Alignment.Center)
+                )
+            }
+        } else if (state.error != null) {
+            Box(modifier = Modifier.fillMaxSize()) {
+                Text(
+                    text = state.error,
+                    color = MaterialTheme.colorScheme.error,
+                    textAlign = TextAlign.Center,
+                    modifier = Modifier.align(Alignment.Center)
+                )
+            }
+        } else {
+            LazyVerticalGrid(
+                columns = GridCells.Adaptive(minSize = 100.dp),
+                modifier = Modifier
+                    .fillMaxSize()
+                    .padding(paddingValues)
+            ) {
+                item {
+                    Box(
+                        modifier = Modifier
+                            .width(100.dp)
+                            .padding(10.dp)
+                    ) {
+                        IconButton(
+                            onClick = { handleEvent(BookmarkEvent.Add) },
+                            modifier = Modifier.align(Alignment.Center)
+                        ) {
+                            Icon(imageVector = Icons.Default.Add, contentDescription = "Add")
+                        }
+                    }
+                }
+                items(state.bookmarks) { item ->
+                    BookmarkItem(item = item, handleEvent = handleEvent, navRoute = navRoute)
+                }
+            }
+        }
+    }
+}

+ 5 - 0
app/src/main/java/com/codeskraps/sbrowser/feature/bookmarks/presentation/mvi/BookmarkAction.kt

@@ -0,0 +1,5 @@
+package com.codeskraps.sbrowser.feature.bookmarks.presentation.mvi
+
+sealed interface BookmarkAction {
+    data class Toast(val message: String) : BookmarkAction
+}

+ 11 - 0
app/src/main/java/com/codeskraps/sbrowser/feature/bookmarks/presentation/mvi/BookmarkEvent.kt

@@ -0,0 +1,11 @@
+package com.codeskraps.sbrowser.feature.bookmarks.presentation.mvi
+
+import com.codeskraps.sbrowser.feature.bookmarks.domain.model.Bookmark
+import com.codeskraps.sbrowser.util.Resource
+
+sealed interface BookmarkEvent {
+    data class Loaded(val bookmarks: Resource<List<Bookmark>>) : BookmarkEvent
+    data object Add : BookmarkEvent
+    data class Edit(val bookmark: Bookmark) : BookmarkEvent
+    data class Delete(val bookmark: Bookmark) : BookmarkEvent
+}

+ 32 - 0
app/src/main/java/com/codeskraps/sbrowser/feature/bookmarks/presentation/mvi/BookmarkState.kt

@@ -0,0 +1,32 @@
+package com.codeskraps.sbrowser.feature.bookmarks.presentation.mvi
+
+import com.codeskraps.sbrowser.feature.bookmarks.domain.model.Bookmark
+
+data class BookmarkState(
+    val isLoading: Boolean,
+    val bookmarks: List<Bookmark>,
+    val error: String? = null
+) {
+    companion object {
+        val initial = BookmarkState(
+            isLoading = false,
+            bookmarks = emptyList()
+        )
+    }
+
+    fun setBookmarks(bookmarks: List<Bookmark>): BookmarkState {
+        return copy(
+            isLoading = false,
+            bookmarks = bookmarks,
+            error = null
+        )
+    }
+
+    fun setError(message: String): BookmarkState {
+        return copy(
+            isLoading = false,
+            bookmarks = emptyList(),
+            error = message
+        )
+    }
+}

+ 73 - 0
app/src/main/java/com/codeskraps/sbrowser/feature/settings/SettingsViewModel.kt

@@ -0,0 +1,73 @@
+package com.codeskraps.sbrowser.feature.settings
+
+import android.webkit.WebSettings.PluginState
+import androidx.lifecycle.viewModelScope
+import com.codeskraps.sbrowser.MediaWebViewPreferences
+import com.codeskraps.sbrowser.feature.settings.mvi.SettingsAction
+import com.codeskraps.sbrowser.feature.settings.mvi.SettingsEvent
+import com.codeskraps.sbrowser.feature.settings.mvi.SettingsState
+import com.codeskraps.sbrowser.feature.webview.media.MediaWebView
+import com.codeskraps.sbrowser.util.StateReducerViewModel
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+@HiltViewModel
+class SettingsViewModel @Inject constructor(
+    private val mediaWebView: MediaWebView,
+    private val mediaWebViewPreferences: MediaWebViewPreferences
+) : StateReducerViewModel<SettingsState, SettingsEvent, SettingsAction>() {
+
+    override fun initState(): SettingsState = SettingsState.initial
+
+    init {
+        viewModelScope.launch(Dispatchers.IO) {
+            state.handleEvent(
+                SettingsEvent.Load(
+                    SettingsState(
+                        homeUrl = mediaWebViewPreferences.homeUrl,
+                        javaScript = mediaWebViewPreferences.javaScript,
+                        plugins = mediaWebViewPreferences.plugins,
+                        userAgent = mediaWebViewPreferences.userAgent
+                    )
+                )
+            )
+        }
+    }
+
+    override fun reduceState(currentState: SettingsState, event: SettingsEvent): SettingsState {
+        return when (event) {
+            is SettingsEvent.Load -> event.state
+            is SettingsEvent.Home -> onHome(currentState, event.url)
+            is SettingsEvent.JavaScript -> onJavaScript(currentState, event.value)
+            is SettingsEvent.Plugins -> onPlugins(currentState, event.value)
+            is SettingsEvent.UserAgent -> onUserAgent(currentState, event.value)
+        }
+    }
+
+    private fun onHome(currentState: SettingsState, url: String): SettingsState {
+        mediaWebViewPreferences.homeUrl = url
+        return currentState.copy(homeUrl = url)
+    }
+
+    private fun onJavaScript(currentState: SettingsState, value: Boolean): SettingsState {
+        mediaWebView.settings.javaScriptEnabled = value
+        mediaWebViewPreferences.javaScript = value
+        return currentState.copy(javaScript = value)
+    }
+
+    private fun onPlugins(currentState: SettingsState, value: PluginState): SettingsState {
+        mediaWebView.settings.pluginState = value
+        mediaWebViewPreferences.plugins = value
+        return currentState.copy(plugins = value)
+    }
+
+    private fun onUserAgent(currentState: SettingsState, value: String): SettingsState {
+        mediaWebView.settings.userAgentString?.let {
+            mediaWebView.settings.userAgentString = it.replace("Android", value)
+        }
+        mediaWebViewPreferences.userAgent = value
+        return currentState.copy(userAgent = value)
+    }
+}

+ 22 - 0
app/src/main/java/com/codeskraps/sbrowser/feature/settings/components/CategoryPreference.kt

@@ -0,0 +1,22 @@
+package com.codeskraps.sbrowser.feature.settings.components
+
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+
+@Composable
+fun CategoryPreference(
+    title: String,
+) {
+    Text(
+        modifier = Modifier
+            .fillMaxWidth()
+            .padding(start = 15.dp, top = 5.dp, end = 15.dp, bottom = 5.dp),
+        color = MaterialTheme.colorScheme.tertiary,
+        text = title
+    )
+}

+ 59 - 0
app/src/main/java/com/codeskraps/sbrowser/feature/settings/components/CheckPreference.kt

@@ -0,0 +1,59 @@
+package com.codeskraps.sbrowser.feature.settings.components
+
+import android.graphics.Paint.Align
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Switch
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+
+@Composable
+fun CheckPreference(
+    title: String,
+    summary: String? = null,
+    value: Boolean,
+    onCheckedChange: ((Boolean) -> Unit)? = null
+) {
+    var isChecked by remember { mutableStateOf(value) }
+    Row(
+        modifier = Modifier
+            .fillMaxWidth()
+            .padding(start = 10.dp, top = 5.dp, end = 10.dp, bottom = 5.dp)
+    ) {
+        Column(
+            modifier = Modifier
+                .align(Alignment.CenterVertically)
+        ) {
+            Text(text = title)
+            if (!summary.isNullOrBlank()) {
+                Text(
+                    color = MaterialTheme.colorScheme.onSecondaryContainer,
+                    fontSize = 14.sp,
+                    text = summary
+                )
+            }
+        }
+        Spacer(modifier = Modifier.weight(1f))
+        Switch(
+            modifier = Modifier
+                .align(Alignment.CenterVertically),
+            checked = isChecked,
+            onCheckedChange = {
+                isChecked = it
+                onCheckedChange?.let { it(isChecked) }
+            }
+        )
+    }
+}

+ 94 - 0
app/src/main/java/com/codeskraps/sbrowser/feature/settings/components/ListPreference.kt

@@ -0,0 +1,94 @@
+package com.codeskraps.sbrowser.feature.settings.components
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.RadioButton
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+
+@Composable
+fun ListPreference(
+    title: String,
+    summary: String,
+    items: Array<String>,
+    onChange: (String) -> Unit
+) {
+    var showDialog by remember { mutableStateOf(false) }
+
+    Column(
+        modifier = Modifier
+            .fillMaxWidth()
+            .padding(start = 10.dp, top = 5.dp, end = 10.dp, bottom = 5.dp)
+            .clickable { showDialog = true }
+    ) {
+        Text(text = title)
+        Text(
+            color = MaterialTheme.colorScheme.onSecondaryContainer,
+            fontSize = 14.sp,
+            text = summary
+        )
+
+        if (showDialog) {
+            ListPreferenceEditDialog(value = summary, dialogTitle = title, items = items, onSave = {
+                onChange(it)
+            }) {
+                showDialog = false
+            }
+        }
+    }
+}
+
+@Composable
+private fun ListPreferenceEditDialog(
+    value: String,
+    dialogTitle: String,
+    items: Array<String>,
+    onSave: (String) -> Unit,
+    onDismissRequest: () -> Unit
+) {
+    var preferenceValue by remember { mutableStateOf(value) }
+
+    AlertDialog(
+        onDismissRequest = { onDismissRequest() },
+        confirmButton = {
+            TextButton(onClick = {
+                onDismissRequest()
+                onSave(preferenceValue)
+            }) {
+                Text(text = "Save")
+            }
+        },
+        dismissButton = {
+            TextButton(onClick = { onDismissRequest() }) {
+                Text(text = "Cancel")
+            }
+        },
+        title = { Text(text = dialogTitle) },
+        text = {
+            Column {
+                items.forEach {
+                    Row {
+                        RadioButton(
+                            selected = preferenceValue == it,
+                            onClick = { preferenceValue = it })
+                        Text(text = it, modifier = Modifier.align(Alignment.CenterVertically))
+                    }
+                }
+            }
+        }
+    )
+}

+ 90 - 0
app/src/main/java/com/codeskraps/sbrowser/feature/settings/components/Preference.kt

@@ -0,0 +1,90 @@
+package com.codeskraps.sbrowser.feature.settings.components
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+
+@Composable
+fun Preference(
+    title: String,
+    summary: String,
+    onChange: ((String) -> Unit)? = null
+) {
+    var showDialog by remember { mutableStateOf(false) }
+
+    var modifier = Modifier
+        .fillMaxWidth()
+        .padding(start = 10.dp, top = 5.dp, end = 10.dp, bottom = 5.dp)
+    onChange?.let {
+        modifier = modifier.clickable {
+            showDialog = true
+        }
+    }
+    Column(modifier = modifier) {
+        Text(text = title)
+        if (summary.isNotBlank()) {
+            Text(
+                color = MaterialTheme.colorScheme.onSecondaryContainer,
+                fontSize = 14.sp,
+                text = summary
+            )
+        }
+    }
+
+    if (showDialog) {
+        PreferenceEditDialog(value = summary, dialogTitle = title, onSave = { newValue ->
+            onChange?.let { it(newValue) }
+        }) {
+            showDialog = false
+        }
+    }
+}
+
+@Composable
+private fun PreferenceEditDialog(
+    value: String,
+    dialogTitle: String,
+    onSave: (String) -> Unit,
+    onDismissRequest: () -> Unit
+) {
+    var preferenceValue by remember { mutableStateOf(value) }
+
+    AlertDialog(
+        onDismissRequest = { onDismissRequest() },
+        confirmButton = {
+            TextButton(onClick = {
+                onDismissRequest()
+                onSave(preferenceValue)
+            }) {
+                Text(text = "Save")
+            }
+        },
+        dismissButton = {
+            TextButton(onClick = { onDismissRequest() }) {
+                Text(text = "Cancel")
+            }
+        },
+        title = { Text(text = dialogTitle) },
+        text = {
+            Column {
+                OutlinedTextField(
+                    value = preferenceValue,
+                    onValueChange = { preferenceValue = it })
+            }
+        }
+    )
+}

+ 77 - 0
app/src/main/java/com/codeskraps/sbrowser/feature/settings/components/SettingsScreen.kt

@@ -0,0 +1,77 @@
+package com.codeskraps.sbrowser.feature.settings.components
+
+import android.webkit.WebSettings.PluginState
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import com.codeskraps.sbrowser.feature.settings.mvi.SettingsEvent
+import com.codeskraps.sbrowser.feature.settings.mvi.SettingsState
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun SettingsScreen(
+    state: SettingsState,
+    handleEvent: (SettingsEvent) -> Unit
+) {
+    Scaffold(
+        modifier = Modifier.fillMaxSize(),
+        containerColor = MaterialTheme.colorScheme.secondary,
+        contentColor = MaterialTheme.colorScheme.onSecondary,
+        topBar = {
+            TopAppBar(title = { Text(text = "Settings") })
+        }
+    ) { paddingValues ->
+
+        val plugins = when (state.plugins) {
+            PluginState.ON -> "Always on"
+            PluginState.ON_DEMAND -> "On demand"
+            else -> "Off"
+        }
+
+        LazyColumn(modifier = Modifier.padding(paddingValues)) {
+            item {
+                CategoryPreference(title = "Page content settings")
+                Preference(title = "Set home page", summary = state.homeUrl) { newValue ->
+                    handleEvent(SettingsEvent.Home(newValue))
+                }
+                SpacerPreference()
+                CheckPreference(title = "Enable JavaScript", value = state.javaScript) { newValue ->
+                    handleEvent(SettingsEvent.JavaScript(newValue))
+                }
+                SpacerPreference()
+                ListPreference(
+                    title = "Enable plug-ins",
+                    summary = plugins,
+                    items = arrayOf("Always on", "On demand", "Off")
+                ) { newValue ->
+                    val userAgent: PluginState = when (newValue) {
+                        "Always on" -> PluginState.ON
+                        "On demand" -> PluginState.ON_DEMAND
+                        else -> PluginState.OFF
+                    }
+                    handleEvent(SettingsEvent.Plugins(userAgent))
+                }
+                SpacerPreference()
+                ListPreference(
+                    title = "Set user agent",
+                    summary = state.userAgent,
+                    items = arrayOf("Default", "Firefox", "Chrome", "Ipad")
+                ) { newValue ->
+                    handleEvent(SettingsEvent.UserAgent(newValue))
+                }
+                CategoryPreference(title = "Information")
+                Preference(
+                    title = "sBrowser v3.0",
+                    summary = "License GNU GPL v3 - 2024 - Codeskraps"
+                )
+            }
+        }
+    }
+}

+ 20 - 0
app/src/main/java/com/codeskraps/sbrowser/feature/settings/components/SpacerPreference.kt

@@ -0,0 +1,20 @@
+package com.codeskraps.sbrowser.feature.settings.components
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+
+@Composable
+fun SpacerPreference(){
+    Spacer(
+        modifier = Modifier
+            .fillMaxWidth()
+            .height(1.dp)
+            .background(MaterialTheme.colorScheme.secondaryContainer)
+    )
+}

+ 3 - 0
app/src/main/java/com/codeskraps/sbrowser/feature/settings/mvi/SettingsAction.kt

@@ -0,0 +1,3 @@
+package com.codeskraps.sbrowser.feature.settings.mvi
+
+sealed interface SettingsAction

+ 11 - 0
app/src/main/java/com/codeskraps/sbrowser/feature/settings/mvi/SettingsEvent.kt

@@ -0,0 +1,11 @@
+package com.codeskraps.sbrowser.feature.settings.mvi
+
+import android.webkit.WebSettings.PluginState
+
+sealed interface SettingsEvent {
+    data class Load(val state: SettingsState) : SettingsEvent
+    data class Home(val url: String) : SettingsEvent
+    data class JavaScript(val value: Boolean) : SettingsEvent
+    data class Plugins(val value: PluginState) : SettingsEvent
+    data class UserAgent(val value: String) : SettingsEvent
+}

+ 20 - 0
app/src/main/java/com/codeskraps/sbrowser/feature/settings/mvi/SettingsState.kt

@@ -0,0 +1,20 @@
+package com.codeskraps.sbrowser.feature.settings.mvi
+
+import android.webkit.WebSettings.PluginState
+import com.codeskraps.sbrowser.util.Constants
+
+data class SettingsState(
+    val homeUrl: String,
+    val javaScript: Boolean,
+    val plugins: PluginState,
+    val userAgent: String
+) {
+    companion object {
+        val initial = SettingsState(
+            homeUrl = Constants.home,
+            javaScript = Constants.javaScript,
+            plugins = Constants.plugins,
+            userAgent = Constants.userAgent
+        )
+    }
+}

+ 33 - 0
app/src/main/java/com/codeskraps/sbrowser/feature/video/VideoViewModel.kt

@@ -0,0 +1,33 @@
+package com.codeskraps.sbrowser.feature.video
+
+import androidx.lifecycle.SavedStateHandle
+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.util.StateReducerViewModel
+import dagger.hilt.android.lifecycle.HiltViewModel
+import javax.inject.Inject
+
+@HiltViewModel
+class VideoViewModel @Inject constructor(
+    private val savedStateHandle: SavedStateHandle
+) : StateReducerViewModel<VideoState, VideoEvent, VideoAction>() {
+    override fun initState(): VideoState = VideoState.initial
+
+    init {
+        savedStateHandle.get<String>("url")?.run {
+            state.handleEvent(VideoEvent.Load(this))
+        }
+    }
+
+    override fun reduceState(currentState: VideoState, event: VideoEvent): VideoState {
+        return when (event) {
+            is VideoEvent.Load -> onLoad(currentState, event.url)
+        }
+    }
+
+    private fun onLoad(currentState: VideoState, url: String): VideoState {
+        savedStateHandle.remove<String>("url")
+        return currentState.copy(url = url)
+    }
+}

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

@@ -0,0 +1,55 @@
+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
+import androidx.compose.ui.viewinterop.AndroidView
+import androidx.media3.common.MediaItem
+import androidx.media3.exoplayer.ExoPlayer
+import androidx.media3.ui.PlayerView
+import com.codeskraps.sbrowser.feature.video.mvi.VideoEvent
+import com.codeskraps.sbrowser.feature.video.mvi.VideoState
+
+@Composable
+fun VideoScreen(
+    state: VideoState,
+    handleEvent: (VideoEvent) -> Unit
+) {
+    if (state.url.isNotBlank()) {
+        val context = LocalContext.current
+        val exoPlayer = remember {
+            ExoPlayer.Builder(context).build().apply {
+                setMediaItem(MediaItem.fromUri(state.url))
+                prepare()
+            }
+        }
+
+        val mediaSource = remember(state.url) {
+            MediaItem.fromUri(state.url)
+        }
+
+        LaunchedEffect(mediaSource) {
+            exoPlayer.setMediaItem(mediaSource)
+            exoPlayer.prepare()
+        }
+
+        DisposableEffect(Unit) {
+            onDispose {
+                exoPlayer.release()
+            }
+        }
+
+        AndroidView(
+            factory = { ctx ->
+                PlayerView(ctx).apply {
+                    player = exoPlayer
+                }
+            },
+            modifier = Modifier.fillMaxSize()
+        )
+    }
+}

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

@@ -0,0 +1,3 @@
+package com.codeskraps.sbrowser.feature.video.mvi
+
+sealed interface VideoAction

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

@@ -0,0 +1,5 @@
+package com.codeskraps.sbrowser.feature.video.mvi
+
+sealed interface VideoEvent {
+    data class Load(val url: String) : VideoEvent
+}

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

@@ -0,0 +1,11 @@
+package com.codeskraps.sbrowser.feature.video.mvi
+
+data class VideoState(
+    val url: String
+) {
+    companion object {
+        val initial = VideoState(
+            url = ""
+        )
+    }
+}

+ 127 - 0
app/src/main/java/com/codeskraps/sbrowser/feature/webview/MediaWebViewModel.kt

@@ -0,0 +1,127 @@
+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
+import com.codeskraps.sbrowser.ForegroundService
+import com.codeskraps.sbrowser.MediaWebViewPreferences
+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.util.BackgroundStatus
+import com.codeskraps.sbrowser.util.Constants
+import com.codeskraps.sbrowser.util.StateReducerViewModel
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+@HiltViewModel
+class MediaWebViewModel @Inject constructor(
+    val mediaWebView: MediaWebView,
+    private val backgroundStatus: BackgroundStatus,
+    private val savedStateHandle: SavedStateHandle,
+    private val mediaWebViewPreferences: MediaWebViewPreferences
+) : StateReducerViewModel<MediaWebViewState, MediaWebViewEvent, MediaWebViewAction>() {
+
+    override fun initState(): MediaWebViewState = MediaWebViewState.initial
+
+    init {
+        mediaWebView.setHandleListener(state::handleEvent)
+
+        viewModelScope.launch(Dispatchers.IO) {
+            backgroundStatus.status.collect {
+                state.handleEvent(MediaWebViewEvent.Background(it))
+            }
+        }
+
+        viewModelScope.launch(Dispatchers.IO) {
+            mediaWebViewPreferences.homeUrl.run {
+                state.handleEvent(MediaWebViewEvent.HomeUrl(this))
+            }
+        }
+
+        val url = savedStateHandle.get<String>("url") ?: ""
+        state.handleEvent(MediaWebViewEvent.Load(url))
+    }
+
+    override fun reduceState(
+        currentState: MediaWebViewState,
+        event: MediaWebViewEvent
+    ): MediaWebViewState {
+        return when (event) {
+            is MediaWebViewEvent.HomeUrl -> currentState.copy(homeUrl = event.homeUrl)
+            is MediaWebViewEvent.Load -> onLoad(currentState, event.url)
+            is MediaWebViewEvent.Loading -> currentState.copy(loading = event.status)
+            is MediaWebViewEvent.ProgressChanged -> currentState.copy(progress = event.progress)
+            is MediaWebViewEvent.Background -> currentState.copy(background = event.status)
+            is MediaWebViewEvent.StartStopService -> onStartStopService(currentState, event.context)
+            is MediaWebViewEvent.Permission -> onPermission(currentState)
+            is MediaWebViewEvent.DownloadService -> onDownloadService(currentState)
+            is MediaWebViewEvent.ActionView -> onActionView(currentState)
+            is MediaWebViewEvent.VideoPlayer -> onVideoPlayer(currentState, event.url)
+        }
+    }
+
+    private fun onLoad(currentState: MediaWebViewState, url: String): MediaWebViewState {
+        mediaWebView.loadUrl(url.ifBlank { Constants.home })
+        savedStateHandle.remove<String>("url")
+        return currentState
+    }
+
+    private fun onStartStopService(
+        currentState: MediaWebViewState,
+        context: Context
+    ): MediaWebViewState {
+        val url = mediaWebView.url
+        viewModelScope.launch(Dispatchers.IO) {
+            if (!currentState.background) {
+                ContextCompat.startForegroundService(
+                    context,
+                    Intent(context, ForegroundService::class.java).apply {
+                        putExtra(Constants.inputExtra, url)
+                    }
+                )
+            } else {
+                context.stopService(Intent(context, ForegroundService::class.java))
+            }
+        }
+        return currentState
+    }
+
+    private fun onPermission(currentState: MediaWebViewState): MediaWebViewState {
+        viewModelScope.launch(Dispatchers.IO) {
+            actionChannel.send(MediaWebViewAction.Toast("Allow Notification Permission in the Device Settings for the app"))
+        }
+        return currentState
+    }
+
+    private fun onDownloadService(currentState: MediaWebViewState): MediaWebViewState {
+        viewModelScope.launch(Dispatchers.IO) {
+            actionChannel.send(MediaWebViewAction.DownloadService)
+        }
+        return currentState
+    }
+
+    private fun onActionView(currentState: MediaWebViewState): MediaWebViewState {
+        viewModelScope.launch(Dispatchers.IO) {
+            actionChannel.send(MediaWebViewAction.ActionView)
+        }
+        return currentState
+    }
+
+    private fun onVideoPlayer(currentState: MediaWebViewState, url: String): MediaWebViewState {
+        viewModelScope.launch(Dispatchers.IO) {
+            actionChannel.send(MediaWebViewAction.VideoPlayer(url))
+        }
+        return currentState
+    }
+
+    override fun onCleared() {
+        super.onCleared()
+        mediaWebView.setHandleListener(null)
+    }
+}

+ 252 - 0
app/src/main/java/com/codeskraps/sbrowser/feature/webview/components/WebViewButtons.kt

@@ -0,0 +1,252 @@
+package com.codeskraps.sbrowser.feature.webview.components
+
+import android.Manifest
+import android.content.pm.PackageManager
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.height
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.ArrowBack
+import androidx.compose.material.icons.filled.ArrowForward
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material.icons.filled.Home
+import androidx.compose.material.icons.filled.MoreVert
+import androidx.compose.material.icons.filled.PlayArrow
+import androidx.compose.material.icons.filled.Refresh
+import androidx.compose.material.icons.filled.Search
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.unit.dp
+import androidx.core.app.ActivityCompat
+import com.codeskraps.sbrowser.MainActivity
+import com.codeskraps.sbrowser.R
+import com.codeskraps.sbrowser.feature.webview.media.MediaWebView
+import com.codeskraps.sbrowser.feature.webview.mvi.MediaWebViewEvent
+import com.codeskraps.sbrowser.feature.webview.mvi.MediaWebViewState
+import com.codeskraps.sbrowser.navigation.Screen
+import com.codeskraps.sbrowser.util.Constants
+
+@Composable
+fun AddressButton(
+    url: String,
+    handleEvent: (MediaWebViewEvent) -> Unit
+) {
+    var showDialog by remember { mutableStateOf(false) }
+
+    IconButton(onClick = { showDialog = true }) {
+        Icon(
+            painter = painterResource(id = R.drawable.ic_web),
+            contentDescription = "Home",
+            tint = MaterialTheme.colorScheme.tertiary
+        )
+    }
+
+    if (showDialog) {
+        AddressEditDialog(url = url, handleEvent = handleEvent) {
+            showDialog = false
+        }
+    }
+}
+
+@Composable
+private fun AddressEditDialog(
+    url: String,
+    handleEvent: (MediaWebViewEvent) -> Unit,
+    onDismissRequest: () -> Unit
+) {
+    var editUrl by remember { mutableStateOf(url) }
+
+    AlertDialog(
+        onDismissRequest = { onDismissRequest() },
+        confirmButton = {
+            TextButton(onClick = {
+                onDismissRequest()
+                handleEvent(MediaWebViewEvent.Load(editUrl))
+            }) {
+                Text(text = "Save")
+            }
+        },
+        dismissButton = {
+            TextButton(onClick = { onDismissRequest() }) {
+                Text(text = "Cancel")
+            }
+        },
+        title = { Text(text = "Edit Current Url") },
+        text = {
+            Column {
+                Text(text = "Url:")
+                Spacer(modifier = Modifier.height(10.dp))
+                OutlinedTextField(value = editUrl, onValueChange = { editUrl = it })
+            }
+        }
+    )
+}
+
+@Composable
+fun HomeButton(
+    homeUrl: String,
+    handleEvent: (MediaWebViewEvent) -> Unit
+) {
+    IconButton(onClick = { handleEvent(MediaWebViewEvent.Load(homeUrl)) }) {
+        Icon(
+            imageVector = Icons.Default.Home,
+            contentDescription = "Home",
+            tint = MaterialTheme.colorScheme.tertiary
+        )
+    }
+}
+
+@Composable
+fun RefreshStopButton(
+    mediaWebView: MediaWebView,
+    state: MediaWebViewState
+) {
+    IconButton(onClick = {
+        if (state.loading) {
+            mediaWebView.stopLoading()
+        } else {
+            mediaWebView.reload()
+        }
+    }) {
+        Icon(
+            imageVector = if (state.loading) {
+                Icons.Default.Close
+            } else {
+                Icons.Default.Refresh
+            },
+            contentDescription = "Stop/Refresh",
+            tint = MaterialTheme.colorScheme.tertiary
+
+        )
+    }
+}
+
+@Composable
+fun GoBackButton(
+    mediaWebView: MediaWebView
+) {
+    IconButton(onClick = {
+        if (mediaWebView.canGoBack()) {
+            mediaWebView.goBack()
+        }
+    }) {
+        Icon(
+            imageVector = Icons.Default.ArrowBack,
+            contentDescription = "GoBack",
+            tint = if (mediaWebView.canGoBack()) {
+                MaterialTheme.colorScheme.tertiary
+            } else {
+                MaterialTheme.colorScheme.secondary
+            }
+        )
+    }
+}
+
+@Composable
+fun GoForwardButton(
+    mediaWebView: MediaWebView
+) {
+    IconButton(onClick = {
+        if (mediaWebView.canGoForward()) {
+            mediaWebView.goForward()
+        }
+    }) {
+        Icon(
+            imageVector = Icons.Default.ArrowForward,
+            contentDescription = "GoForward",
+            tint = if (mediaWebView.canGoForward()) {
+                MaterialTheme.colorScheme.tertiary
+            } else {
+                MaterialTheme.colorScheme.secondary
+            }
+        )
+    }
+}
+
+@Composable
+fun BackgroundButton(
+    state: MediaWebViewState,
+    handleEvent: (MediaWebViewEvent) -> Unit
+) {
+
+    val context = LocalContext.current
+    val activity = context as MainActivity
+
+    IconButton(onClick = {
+        if (ActivityCompat.checkSelfPermission(
+                activity,
+                Manifest.permission.POST_NOTIFICATIONS
+            ) == PackageManager.PERMISSION_GRANTED
+        ) {
+            handleEvent(MediaWebViewEvent.StartStopService(context))
+        } else {
+            handleEvent(MediaWebViewEvent.Permission)
+        }
+    }) {
+        Icon(
+            imageVector = Icons.Default.PlayArrow,
+            contentDescription = "Background",
+            tint = if (state.background) {
+                MaterialTheme.colorScheme.tertiary
+            } else {
+                MaterialTheme.colorScheme.secondary
+            }
+        )
+    }
+}
+
+@Composable
+fun SearchButton(
+    handleEvent: (MediaWebViewEvent) -> Unit
+) {
+    IconButton(onClick = { handleEvent(MediaWebViewEvent.Load(Constants.home)) }) {
+        Icon(
+            imageVector = Icons.Default.Search,
+            contentDescription = "Home",
+            tint = MaterialTheme.colorScheme.tertiary
+        )
+    }
+}
+
+@Composable
+fun MenuButton(
+    navRoute: (String) -> Unit
+) {
+    var expanded by remember { mutableStateOf(false) }
+
+    IconButton(onClick = { expanded = !expanded }) {
+        Icon(
+            imageVector = Icons.Default.MoreVert,
+            contentDescription = "Home",
+            tint = MaterialTheme.colorScheme.tertiary
+        )
+        DropdownMenu(
+            expanded = expanded,
+            onDismissRequest = { expanded = false }
+        ) {
+            DropdownMenuItem(
+                text = { Text("Bookmarks") },
+                onClick = { navRoute(Screen.Bookmarks.route) }
+            )
+            DropdownMenuItem(
+                text = { Text("Settings") },
+                onClick = { navRoute(Screen.Settings.route) }
+            )
+        }
+    }
+}

+ 21 - 0
app/src/main/java/com/codeskraps/sbrowser/feature/webview/components/WebViewProgressIndicator.kt

@@ -0,0 +1,21 @@
+package com.codeskraps.sbrowser.feature.webview.components
+
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.material3.LinearProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import com.codeskraps.sbrowser.feature.webview.mvi.MediaWebViewState
+
+@Composable
+fun WebViewProgressIndicator(
+    state: MediaWebViewState
+) {
+    if (state.loading) {
+        LinearProgressIndicator(
+            progress = state.progress,
+            modifier = Modifier.fillMaxWidth(),
+            color = MaterialTheme.colorScheme.tertiary
+        )
+    }
+}

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

@@ -0,0 +1,167 @@
+package com.codeskraps.sbrowser.feature.webview.components
+
+import android.app.DownloadManager
+import android.content.Context
+import android.content.Intent
+import android.content.Intent.CATEGORY_DEFAULT
+import android.content.Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS
+import android.content.Intent.FLAG_ACTIVITY_NEW_TASK
+import android.content.Intent.FLAG_ACTIVITY_NO_HISTORY
+import android.content.res.Configuration
+import android.net.Uri
+import android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS
+import androidx.activity.compose.BackHandler
+import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.SnackbarDuration
+import androidx.compose.material3.SnackbarHost
+import androidx.compose.material3.SnackbarHostState
+import androidx.compose.material3.SnackbarResult
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalConfiguration
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.viewinterop.AndroidView
+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.navigation.Screen
+import com.codeskraps.sbrowser.util.components.ObserveAsEvents
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.launch
+
+@Composable
+fun WebViewScreen(
+    mediaWebView: MediaWebView,
+    state: MediaWebViewState,
+    handleEvent: (MediaWebViewEvent) -> Unit,
+    action: Flow<MediaWebViewAction>,
+    navRoute: (String) -> Unit
+) {
+    val context = LocalContext.current
+    val configuration = LocalConfiguration.current
+    val snackbarHostState = remember { SnackbarHostState() }
+    val scope = rememberCoroutineScope()
+    val backPressDispatcher = LocalOnBackPressedDispatcherOwner.current
+
+    ObserveAsEvents(flow = action) { onAction ->
+        when (onAction) {
+            is MediaWebViewAction.Toast -> {
+                scope.launch {
+                    val result = snackbarHostState.showSnackbar(
+                        message = onAction.message,
+                        actionLabel = "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)
+                            })
+                        }
+                    }
+                }
+            }
+
+            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)
+                    })
+                }
+            }
+
+            is MediaWebViewAction.ActionView -> {
+                context.startActivity(
+                    Intent(
+                        Intent.ACTION_VIEW,
+                        Uri.parse(mediaWebView.url)
+                    ).apply { flags = FLAG_ACTIVITY_NEW_TASK }
+                )
+            }
+
+            is MediaWebViewAction.VideoPlayer -> {
+                navRoute(Screen.Video.createRoute(onAction.url))
+            }
+        }
+    }
+
+    BackHandler {
+        if (mediaWebView.canGoBack()) {
+            mediaWebView.goBack()
+        } else {
+            backPressDispatcher?.onBackPressedDispatcher?.onBackPressed()
+        }
+    }
+
+    Scaffold(
+        snackbarHost = { SnackbarHost(snackbarHostState) },
+        bottomBar = {
+            if (configuration.orientation == Configuration.ORIENTATION_PORTRAIT) {
+                Box(modifier = Modifier.fillMaxWidth()) {
+                    WebViewProgressIndicator(state = state)
+                    Row(modifier = Modifier.fillMaxWidth()) {
+                        AddressButton(url = mediaWebView.url ?: "", handleEvent = handleEvent)
+                        GoBackButton(mediaWebView = mediaWebView)
+                        GoForwardButton(mediaWebView = mediaWebView)
+                        HomeButton(homeUrl = state.homeUrl, handleEvent = handleEvent)
+                        RefreshStopButton(mediaWebView, state)
+                        //SearchButton(handleEvent = handleEvent)
+                        BackgroundButton(state = state, handleEvent = handleEvent)
+                        Spacer(modifier = Modifier.weight(1f))
+                        MenuButton(navRoute = navRoute)
+                    }
+                }
+            }
+        }
+    ) { paddingValues ->
+        Row(modifier = Modifier.padding(paddingValues)) {
+            if (configuration.orientation == Configuration.ORIENTATION_LANDSCAPE) {
+                Column {
+                    AddressButton(url = mediaWebView.url ?: "", handleEvent = handleEvent)
+                    GoBackButton(mediaWebView = mediaWebView)
+                    GoForwardButton(mediaWebView = mediaWebView)
+                    HomeButton(homeUrl = state.homeUrl, handleEvent = handleEvent)
+                    RefreshStopButton(mediaWebView, state)
+                    //SearchButton(handleEvent = handleEvent)
+                    BackgroundButton(state = state, handleEvent = handleEvent)
+                    Spacer(modifier = Modifier.weight(1f))
+                    MenuButton(navRoute = navRoute)
+                }
+            }
+            Column {
+                if (configuration.orientation == Configuration.ORIENTATION_LANDSCAPE) {
+                    WebViewProgressIndicator(state = state)
+                }
+
+                AndroidView(
+                    modifier = Modifier.fillMaxSize(),
+                    factory = { _ ->
+                        mediaWebView.detachView()
+                        mediaWebView.attachView
+                    },
+                    update = { _ ->
+                        mediaWebView.iniLoad()
+                    }
+                )
+            }
+        }
+    }
+}

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

@@ -0,0 +1,94 @@
+package com.codeskraps.sbrowser.feature.webview.media
+
+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.util.regex.Matcher
+import java.util.regex.Pattern
+
+
+class HandleVideo {
+    companion object {
+        private val TAG = HandleVideo::class.java.simpleName
+    }
+
+    operator fun invoke(url: String, result: (String) -> Unit) {
+        try {
+            val doc: Document = Jsoup.connect(url).get()
+            var metalinks: Elements = doc.select("div[id=player]")
+
+            if (!metalinks.isEmpty()) {
+                val elements: Elements = metalinks.select("script")
+
+                if (!elements.isEmpty()) {
+                    val iterator: Iterator<Element> = elements.iterator()
+
+                    while (iterator.hasNext()) {
+                        var html: String = iterator.next().html()
+                        Log.w(TAG, html)
+
+                        if (html.contains("HTML5Player")) {
+                            html = html.substring(html.indexOf("HTML5Player"))
+                            val m: Matcher = Pattern.compile("\\((.*?)\\)").matcher(html)
+
+                            while (m.find()) {
+                                Log.v(TAG, "m:" + m.group(1))
+                                m.group(1)?.let { groupOne ->
+                                    val attr =
+                                        groupOne.split(",".toRegex())
+                                            .dropLastWhile { it.isEmpty() }
+                                            .toTypedArray()
+                                    Log.v(TAG, "attr:" + attr[3])
+                                    val index1 = attr[3].indexOf("\'") + 1
+                                    val index2 = attr[3].indexOf("\'", index1)
+                                    Log.v(TAG, "video:${attr[3]}")
+                                    result(attr[3].substring(index1, index2))
+                                }
+                            }
+                        }
+                    }
+                }
+            }
+
+            metalinks = doc.select("a[id=play]")
+
+            if (!metalinks.isEmpty()) {
+                Log.v(TAG, "video:${metalinks.firstOrNull()}")
+                metalinks.first()?.let {
+                    result(it.attr("href"))
+                }
+            }
+
+            Log.e(TAG, "script")
+            metalinks = doc.select("script")
+
+            if (!metalinks.isEmpty()) {
+                val iterator: Iterator<Element> = metalinks.iterator()
+
+                while (iterator.hasNext()) {
+                    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))
+                            }
+                        }
+                    }
+                }
+            }
+        } catch (e: Exception) {
+            Log.e(TAG, "Handled - HandleVideo:$e", e)
+        }
+    }
+}

+ 15 - 0
app/src/main/java/com/codeskraps/sbrowser/feature/webview/media/MediaWebChromeClient.kt

@@ -0,0 +1,15 @@
+package com.codeskraps.sbrowser.feature.webview.media
+
+import android.webkit.WebChromeClient
+import android.webkit.WebView
+import com.codeskraps.sbrowser.feature.webview.mvi.MediaWebViewEvent
+
+class MediaWebChromeClient : WebChromeClient() {
+
+    var handleEvent: ((MediaWebViewEvent) -> Unit)? = null
+
+    override fun onProgressChanged(view: WebView?, newProgress: Int) {
+        super.onProgressChanged(view, newProgress)
+        handleEvent?.let { it(MediaWebViewEvent.ProgressChanged(newProgress.toFloat())) }
+    }
+}

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

@@ -0,0 +1,148 @@
+package com.codeskraps.sbrowser.feature.webview.media
+
+import android.annotation.SuppressLint
+import android.app.Application
+import android.content.Context
+import android.graphics.Bitmap
+import android.graphics.Canvas
+import android.util.AttributeSet
+import android.view.View
+import android.view.ViewGroup
+import android.webkit.CookieManager
+import android.webkit.WebSettings
+import android.webkit.WebSettings.PluginState
+import android.webkit.WebView
+import com.codeskraps.sbrowser.MediaWebViewPreferences
+import com.codeskraps.sbrowser.feature.webview.mvi.MediaWebViewEvent
+import com.codeskraps.sbrowser.util.Constants
+import dagger.hilt.android.qualifiers.ApplicationContext
+import java.io.ByteArrayOutputStream
+import javax.inject.Inject
+
+@SuppressLint("SetJavaScriptEnabled")
+class MediaWebView @Inject constructor(
+    private val application: Application,
+    private val mediaWebViewPreferences: MediaWebViewPreferences
+) {
+
+    private val webView by lazy {
+        InternalWebView(application).apply {
+            webViewClient = MediaWebViewClient()
+            webChromeClient = MediaWebChromeClient()
+        }
+    }
+
+    private var initLoad: Boolean = false
+    val attachView: View
+        get() = webView
+    val url: String?
+        get() = webView.url
+    val title: String?
+        get() = webView.title
+    val settings: WebSettings
+        get() = webView.settings
+
+    fun iniLoad() {
+        if (!initLoad) {
+            initLoad = true
+            loadUrl(mediaWebViewPreferences.homeUrl)
+        }
+    }
+
+    fun setUrlListener(urlListener: ((String) -> Unit)?) {
+        (webView.webViewClient as MediaWebViewClient).urlListener = urlListener
+    }
+
+    fun setHandleListener(handleEvent: ((MediaWebViewEvent) -> Unit)?) {
+        (webView.webChromeClient as MediaWebChromeClient).handleEvent = handleEvent
+        (webView.webViewClient as MediaWebViewClient).handleEvent = handleEvent
+    }
+
+    fun detachView() {
+        webView.parent?.let {
+            (it as ViewGroup).removeView(webView)
+        }
+    }
+
+    fun loadUrl(url: String) {
+        webView.loadUrl(url)
+    }
+
+    fun stopLoading() {
+        webView.stopLoading()
+    }
+
+    fun reload() {
+        webView.reload()
+    }
+
+    fun canGoBack(): Boolean = webView.canGoBack()
+    fun canGoForward(): Boolean = webView.canGoForward()
+
+    fun goBack() {
+        webView.goBack()
+    }
+
+    fun goForward() {
+        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
+        }
+    }
+
+    inner class InternalWebView : WebView {
+        constructor(context: Context?) : super(context!!)
+        constructor(context: Context?, attrs: AttributeSet?) : super(
+            context!!, attrs
+        )
+
+        constructor(context: Context?, attrs: AttributeSet?, defStyleAttr: Int) : super(
+            context!!, attrs, defStyleAttr
+        )
+
+        override fun onWindowVisibilityChanged(visibility: Int) {
+            if (visibility != View.GONE && visibility != View.INVISIBLE) {
+                super.onWindowVisibilityChanged(visibility)
+            }
+        }
+
+        init {
+            with(settings) {
+                loadsImagesAutomatically = true
+                javaScriptEnabled = mediaWebViewPreferences.javaScript
+                domStorageEnabled = true
+                loadWithOverviewMode = true
+                useWideViewPort = true
+                builtInZoomControls = true
+                //displayZoomControls = true
+                mediaPlaybackRequiresUserGesture = false
+                pluginState = mediaWebViewPreferences.plugins
+                allowFileAccess = true
+
+                setInitialScale(200)
+                setSupportZoom(true)
+                setNetworkAvailable(true)
+
+                if (mediaWebViewPreferences.userAgent != Constants.userAgent) {
+                    userAgentString =
+                        userAgentString.replace("Android", mediaWebViewPreferences.userAgent)
+                }
+            }
+
+            CookieManager.getInstance().setAcceptThirdPartyCookies(this@InternalWebView, true)
+        }
+    }
+}

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

@@ -0,0 +1,56 @@
+package com.codeskraps.sbrowser.feature.webview.media
+
+import android.graphics.Bitmap
+import android.webkit.MimeTypeMap
+import android.webkit.WebResourceRequest
+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.util.Locale
+
+
+class MediaWebViewClient : WebViewClient() {
+
+    var urlListener: ((String) -> Unit)? = null
+    var handleEvent: ((MediaWebViewEvent) -> Unit)? = null
+
+    override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
+        super.onPageStarted(view, url, favicon)
+        handleEvent?.let { it(MediaWebViewEvent.Loading(true)) }
+    }
+
+    override fun onPageFinished(view: WebView?, url: String?) {
+        super.onPageFinished(view, url)
+        handleEvent?.let { it(MediaWebViewEvent.Loading(false)) }
+    }
+
+    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)) }
+                }
+            }
+        }
+
+        return super.shouldOverrideUrlLoading(view, request)
+    }
+}

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

@@ -0,0 +1,8 @@
+package com.codeskraps.sbrowser.feature.webview.mvi
+
+sealed interface MediaWebViewAction {
+    data class Toast(val message: String) : MediaWebViewAction
+    data object DownloadService : MediaWebViewAction
+    data object ActionView : MediaWebViewAction
+    data class VideoPlayer(val url: String) : MediaWebViewAction
+}

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

@@ -0,0 +1,16 @@
+package com.codeskraps.sbrowser.feature.webview.mvi
+
+import android.content.Context
+
+sealed interface MediaWebViewEvent {
+    data class HomeUrl(val homeUrl: String) : MediaWebViewEvent
+    data class Load(val url: String) : MediaWebViewEvent
+    data class Loading(val status: Boolean) : MediaWebViewEvent
+    data class ProgressChanged(val progress: Float) : MediaWebViewEvent
+    data class Background(val status: Boolean) : MediaWebViewEvent
+    data class StartStopService(val context: Context) : MediaWebViewEvent
+    data object Permission : MediaWebViewEvent
+    data class VideoPlayer(val url: String) : MediaWebViewEvent
+    data object DownloadService : MediaWebViewEvent
+    data object ActionView : MediaWebViewEvent
+}

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

@@ -0,0 +1,19 @@
+package com.codeskraps.sbrowser.feature.webview.mvi
+
+import com.codeskraps.sbrowser.util.Constants
+
+data class MediaWebViewState(
+    val homeUrl: String,
+    val loading: Boolean,
+    val progress: Float,
+    val background: Boolean
+) {
+    companion object {
+        val initial = MediaWebViewState(
+            homeUrl = Constants.home,
+            loading = false,
+            progress = .0f,
+            background = false
+        )
+    }
+}

+ 22 - 0
app/src/main/java/com/codeskraps/sbrowser/navigation/Screen.kt

@@ -0,0 +1,22 @@
+package com.codeskraps.sbrowser.navigation
+
+import java.net.URLEncoder
+import java.nio.charset.StandardCharsets
+
+sealed class Screen(val route: String) {
+    data object WebView : Screen("webView/{url}") {
+        fun createRoute(url: String): String {
+            val encoded = URLEncoder.encode(url, StandardCharsets.UTF_8.toString())
+            return "webView/$encoded"
+        }
+    }
+
+    data object Bookmarks : Screen("bookmarks")
+    data object Settings : Screen("settings")
+    data object Video : Screen("video/{url}") {
+        fun createRoute(url: String): String {
+            val encoded = URLEncoder.encode(url, StandardCharsets.UTF_8.toString())
+            return "video/$encoded"
+        }
+    }
+}

+ 15 - 0
app/src/main/java/com/codeskraps/sbrowser/ui/theme/Color.kt

@@ -0,0 +1,15 @@
+package com.codeskraps.sbrowser.ui.theme
+
+import androidx.compose.ui.graphics.Color
+
+// Neutrals
+val dark_100 = Color(0xFF2C3A42)
+val dark_95 = Color(0xFF37444B)
+val dark_85 = Color(0xFF4C585E)
+val dark_80 = Color(0xFF566168)
+val dark_60 = Color(0xFF80898E)
+val dark_40 = Color(0xFFABB0B3)
+val dark_20 = Color(0xFFD5D8D9)
+val dark_15 = Color(0xFFDFE1E3)
+val dark_5 = Color(0xFFF4F5F6)
+val white = Color(0xFFFFFFFF)

+ 60 - 0
app/src/main/java/com/codeskraps/sbrowser/ui/theme/Theme.kt

@@ -0,0 +1,60 @@
+package com.codeskraps.sbrowser.ui.theme
+
+import android.app.Activity
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.darkColorScheme
+import androidx.compose.material3.lightColorScheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.SideEffect
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.toArgb
+import androidx.compose.ui.platform.LocalView
+import androidx.core.view.WindowCompat
+
+private val DarkColorScheme = darkColorScheme(
+    primary = Color.White,
+    onPrimary = Color.Black,
+    secondary = dark_100,
+    onSecondary = dark_5,
+    onSecondaryContainer = dark_20,
+    secondaryContainer = dark_85,
+    tertiary = Color(0xFFDCAF28),
+    background = Color.Black,
+    onBackground = Color.White
+)
+
+private val LightColorScheme = lightColorScheme(
+    primary = Color.Black,
+    onPrimary = Color.White,
+    secondary = dark_5,
+    onSecondary = dark_100,
+    onSecondaryContainer = dark_85,
+    secondaryContainer = dark_15,
+    tertiary = Color(0xFFDCAF28),
+    background = Color.White,
+    onBackground = Color.Black
+)
+
+@Composable
+fun SBrowserTheme(
+    darkTheme: Boolean = isSystemInDarkTheme(),
+    content: @Composable () -> Unit
+) {
+    val colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme
+
+    val view = LocalView.current
+    if (!view.isInEditMode) {
+        SideEffect {
+            val window = (view.context as Activity).window
+            window.statusBarColor = colorScheme.background.toArgb()
+            WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme
+        }
+    }
+
+    MaterialTheme(
+        colorScheme = colorScheme,
+        typography = Typography,
+        content = content
+    )
+}

+ 34 - 0
app/src/main/java/com/codeskraps/sbrowser/ui/theme/Type.kt

@@ -0,0 +1,34 @@
+package com.codeskraps.sbrowser.ui.theme
+
+import androidx.compose.material3.Typography
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.sp
+
+// Set of Material typography styles to start with
+val Typography = Typography(
+    bodyLarge = TextStyle(
+        fontFamily = FontFamily.Default,
+        fontWeight = FontWeight.Normal,
+        fontSize = 16.sp,
+        lineHeight = 24.sp,
+        letterSpacing = 0.5.sp
+    )
+    /* Other default text styles to override
+    titleLarge = TextStyle(
+        fontFamily = FontFamily.Default,
+        fontWeight = FontWeight.Normal,
+        fontSize = 22.sp,
+        lineHeight = 28.sp,
+        letterSpacing = 0.sp
+    ),
+    labelSmall = TextStyle(
+        fontFamily = FontFamily.Default,
+        fontWeight = FontWeight.Medium,
+        fontSize = 11.sp,
+        lineHeight = 16.sp,
+        letterSpacing = 0.5.sp
+    )
+    */
+)

+ 19 - 0
app/src/main/java/com/codeskraps/sbrowser/util/BackgroundStatus.kt

@@ -0,0 +1,19 @@
+package com.codeskraps.sbrowser.util
+
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+
+class BackgroundStatus {
+
+    private val _status by lazy { MutableStateFlow(false) }
+    val status = _status.asStateFlow()
+
+    fun setValue(value: Boolean) {
+        CoroutineScope(Dispatchers.IO).run {
+            launch { _status.emit(value) }
+        }
+    }
+}

+ 11 - 0
app/src/main/java/com/codeskraps/sbrowser/util/Constants.kt

@@ -0,0 +1,11 @@
+package com.codeskraps.sbrowser.util
+
+import android.webkit.WebSettings.PluginState
+
+object Constants {
+    const val home = "https://www.google.com/"
+    const val javaScript = true
+    val plugins = PluginState.ON
+    const val userAgent = "Default"
+    const val inputExtra = "inputExtra"
+}

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

@@ -0,0 +1,6 @@
+package com.codeskraps.sbrowser.util
+
+sealed interface Resource<T> {
+    class Success<T>(val data: T) : Resource<T>
+    class Error<T>(val message: String) : Resource<T>
+}

+ 68 - 0
app/src/main/java/com/codeskraps/sbrowser/util/StateReducerFlow.kt

@@ -0,0 +1,68 @@
+package com.codeskraps.sbrowser.util
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.channels.Channel.Factory.BUFFERED
+import kotlinx.coroutines.flow.*
+import kotlinx.coroutines.flow.SharingStarted.Companion.Eagerly
+
+interface StateReducerFlow<STATE, EVENT> : StateFlow<STATE> {
+    fun handleEvent(event: EVENT)
+}
+
+fun <STATE, EVENT> ViewModel.StateReducerFlow(
+    initialState: STATE,
+    reduceState: (STATE, EVENT) -> STATE,
+): StateReducerFlow<STATE, EVENT> = StateReducerFlow(initialState, reduceState, viewModelScope)
+
+fun <STATE, EVENT> StateReducerFlow(
+    initialState: STATE,
+    reduceState: (STATE, EVENT) -> STATE,
+    scope: CoroutineScope
+): StateReducerFlow<STATE, EVENT> = StateReducerFlowImpl(initialState, reduceState, scope)
+
+private class StateReducerFlowImpl<STATE, EVENT>(
+    initialState: STATE,
+    reduceState: (STATE, EVENT) -> STATE,
+    scope: CoroutineScope
+) : StateReducerFlow<STATE, EVENT> {
+
+    private val events = Channel<EVENT>(BUFFERED)
+
+    private val stateFlow = events
+        .receiveAsFlow()
+        .runningFold(initialState, reduceState)
+        .stateIn(scope, Eagerly, initialState)
+
+    override val replayCache get() = stateFlow.replayCache
+
+    override val value get() = stateFlow.value
+
+    override suspend fun collect(collector: FlowCollector<STATE>): Nothing {
+        stateFlow.collect(collector)
+    }
+
+    override fun handleEvent(event: EVENT) {
+        val delivered = events.trySend(event).isSuccess
+        if (!delivered) {
+            error("Missed event $event! You are doing something wrong during state transformation.")
+        }
+    }
+}
+
+abstract class StateReducerViewModel<STATE, EVENT, ACTION> : ViewModel() {
+
+    private val initState by lazy { initState() }
+    val state = StateReducerFlow(
+        initialState = initState,
+        reduceState = ::reduceState
+    )
+
+    protected val actionChannel = Channel<ACTION>()
+    val action = actionChannel.receiveAsFlow()
+
+    protected abstract fun initState(): STATE
+    protected abstract fun reduceState(currentState: STATE, event: EVENT): STATE
+}

+ 22 - 0
app/src/main/java/com/codeskraps/sbrowser/util/components/ObserveAsEvent.kt

@@ -0,0 +1,22 @@
+package com.codeskraps.sbrowser.util.components
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.ui.platform.LocalLifecycleOwner
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.repeatOnLifecycle
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.withContext
+
+@Composable
+fun <T> ObserveAsEvents(flow: Flow<T>, onAction: (T) -> Unit) {
+    val lifecycleOwner = LocalLifecycleOwner.current
+    LaunchedEffect(flow, lifecycleOwner) {
+        lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
+            withContext(Dispatchers.Main.immediate) {
+                flow.collect(onAction)
+            }
+        }
+    }
+}

BIN
app/src/main/res/drawable-night/ic_notification.png


+ 10 - 0
app/src/main/res/drawable/ic_home.xml

@@ -0,0 +1,10 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="24dp"
+    android:height="24dp"
+    android:tint="#FFFFFF"
+    android:viewportWidth="24"
+    android:viewportHeight="24">
+    <path
+        android:fillColor="@color/notification_icon"
+        android:pathData="M10,20v-6h4v6h5v-8h3L12,3 2,12h3v8z" />
+</vector>

+ 170 - 0
app/src/main/res/drawable/ic_launcher_background.xml

@@ -0,0 +1,170 @@
+<?xml version="1.0" encoding="utf-8"?>
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="108dp"
+    android:height="108dp"
+    android:viewportWidth="108"
+    android:viewportHeight="108">
+    <path
+        android:fillColor="#3DDC84"
+        android:pathData="M0,0h108v108h-108z" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M9,0L9,108"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M19,0L19,108"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M29,0L29,108"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M39,0L39,108"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M49,0L49,108"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M59,0L59,108"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M69,0L69,108"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M79,0L79,108"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M89,0L89,108"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M99,0L99,108"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M0,9L108,9"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M0,19L108,19"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M0,29L108,29"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M0,39L108,39"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M0,49L108,49"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M0,59L108,59"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M0,69L108,69"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M0,79L108,79"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M0,89L108,89"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M0,99L108,99"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M19,29L89,29"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M19,39L89,39"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M19,49L89,49"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M19,59L89,59"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M19,69L89,69"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M19,79L89,79"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M29,19L29,89"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M39,19L39,89"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M49,19L49,89"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M59,19L59,89"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M69,19L69,89"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M79,19L79,89"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+</vector>

+ 30 - 0
app/src/main/res/drawable/ic_launcher_foreground.xml

@@ -0,0 +1,30 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:aapt="http://schemas.android.com/aapt"
+    android:width="108dp"
+    android:height="108dp"
+    android:viewportWidth="108"
+    android:viewportHeight="108">
+    <path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
+        <aapt:attr name="android:fillColor">
+            <gradient
+                android:endX="85.84757"
+                android:endY="92.4963"
+                android:startX="42.9492"
+                android:startY="49.59793"
+                android:type="linear">
+                <item
+                    android:color="#44000000"
+                    android:offset="0.0" />
+                <item
+                    android:color="#00000000"
+                    android:offset="1.0" />
+            </gradient>
+        </aapt:attr>
+    </path>
+    <path
+        android:fillColor="#FFFFFF"
+        android:fillType="nonZero"
+        android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
+        android:strokeWidth="1"
+        android:strokeColor="#00000000" />
+</vector>

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


+ 10 - 0
app/src/main/res/drawable/ic_refresh.xml

@@ -0,0 +1,10 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="24dp"
+    android:height="24dp"
+    android:tint="#FFFFFF"
+    android:viewportWidth="24"
+    android:viewportHeight="24">
+    <path
+        android:fillColor="@color/notification_icon"
+        android:pathData="M17.65,6.35C16.2,4.9 14.21,4 12,4c-4.42,0 -7.99,3.58 -7.99,8s3.57,8 7.99,8c3.73,0 6.84,-2.55 7.73,-6h-2.08c-0.82,2.33 -3.04,4 -5.65,4 -3.31,0 -6,-2.69 -6,-6s2.69,-6 6,-6c1.66,0 3.14,0.69 4.22,1.78L13,11h7V4l-2.35,2.35z" />
+</vector>

+ 5 - 0
app/src/main/res/drawable/ic_web.xml

@@ -0,0 +1,5 @@
+<vector android:height="24dp" android:tint="#000000"
+    android:viewportHeight="24" android:viewportWidth="24"
+    android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
+    <path android:fillColor="@android:color/white" android:pathData="M20,4L4,4c-1.1,0 -1.99,0.9 -1.99,2L2,18c0,1.1 0.9,2 2,2h16c1.1,0 2,-0.9 2,-2L22,6c0,-1.1 -0.9,-2 -2,-2zM15,18L4,18v-4h11v4zM15,13L4,13L4,9h11v4zM20,18h-4L16,9h4v9z"/>
+</vector>

+ 6 - 0
app/src/main/res/mipmap-anydpi/ic_launcher.xml

@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="utf-8"?>
+<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
+    <background android:drawable="@drawable/ic_launcher_background" />
+    <foreground android:drawable="@drawable/ic_launcher_foreground" />
+    <monochrome android:drawable="@drawable/ic_launcher_foreground" />
+</adaptive-icon>

+ 6 - 0
app/src/main/res/mipmap-anydpi/ic_launcher_round.xml

@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="utf-8"?>
+<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
+    <background android:drawable="@drawable/ic_launcher_background" />
+    <foreground android:drawable="@drawable/ic_launcher_foreground" />
+    <monochrome android:drawable="@drawable/ic_launcher_foreground" />
+</adaptive-icon>

BIN
app/src/main/res/mipmap-hdpi/ic_launcher.webp


BIN
app/src/main/res/mipmap-hdpi/ic_launcher_round.webp


BIN
app/src/main/res/mipmap-mdpi/ic_launcher.webp


BIN
app/src/main/res/mipmap-mdpi/ic_launcher_round.webp


BIN
app/src/main/res/mipmap-xhdpi/ic_launcher.webp


BIN
app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp


BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher.webp


BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp


BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp


BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp


+ 5 - 0
app/src/main/res/values-night/colors.xml

@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+    <color name="window_background">#FF000000</color>
+    <color name="notification_icon">#FFFFFFFF</color>
+</resources>

+ 5 - 0
app/src/main/res/values/colors.xml

@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+    <color name="window_background">#FFFFFFFF</color>
+    <color name="notification_icon">#FF000000</color>
+</resources>

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

@@ -0,0 +1,3 @@
+<resources>
+    <string name="app_name">sBrowser</string>
+</resources>

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

@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+
+    <style name="Theme.SBrowser" parent="android:Theme.Material.Light.NoActionBar">
+        <item name="android:windowBackground">@color/window_background</item>
+    </style>
+</resources>

+ 13 - 0
app/src/main/res/xml/backup_rules.xml

@@ -0,0 +1,13 @@
+<?xml version="1.0" encoding="utf-8"?><!--
+   Sample backup rules file; uncomment and customize as necessary.
+   See https://developer.android.com/guide/topics/data/autobackup
+   for details.
+   Note: This file is ignored for devices older that API 31
+   See https://developer.android.com/about/versions/12/backup-restore
+-->
+<full-backup-content>
+    <!--
+   <include domain="sharedpref" path="."/>
+   <exclude domain="sharedpref" path="device.xml"/>
+-->
+</full-backup-content>

+ 19 - 0
app/src/main/res/xml/data_extraction_rules.xml

@@ -0,0 +1,19 @@
+<?xml version="1.0" encoding="utf-8"?><!--
+   Sample data extraction rules file; uncomment and customize as necessary.
+   See https://developer.android.com/about/versions/12/backup-restore#xml-changes
+   for details.
+-->
+<data-extraction-rules>
+    <cloud-backup>
+        <!-- TODO: Use <include> and <exclude> to control what is backed up.
+        <include .../>
+        <exclude .../>
+        -->
+    </cloud-backup>
+    <!--
+    <device-transfer>
+        <include .../>
+        <exclude .../>
+    </device-transfer>
+    -->
+</data-extraction-rules>

+ 17 - 0
app/src/test/java/com/codeskraps/sbrowser/ExampleUnitTest.kt

@@ -0,0 +1,17 @@
+package com.codeskraps.sbrowser
+
+import org.junit.Test
+
+import org.junit.Assert.*
+
+/**
+ * Example local unit test, which will execute on the development machine (host).
+ *
+ * See [testing documentation](http://d.android.com/tools/testing).
+ */
+class ExampleUnitTest {
+    @Test
+    fun addition_isCorrect() {
+        assertEquals(4, 2 + 2)
+    }
+}

+ 14 - 0
build.gradle.kts

@@ -0,0 +1,14 @@
+// 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
+}
+
+tasks.register("clean", Delete::class) {
+    delete(rootProject.buildDir)
+}
+
+true // Needed to make the Suppress annotation work for the plugins block

+ 23 - 0
gradle.properties

@@ -0,0 +1,23 @@
+# Project-wide Gradle settings.
+# IDE (e.g. Android Studio) users:
+# Gradle settings configured through the IDE *will override*
+# any settings specified in this file.
+# For more details on how to configure your build environment visit
+# http://www.gradle.org/docs/current/userguide/build_environment.html
+# Specifies the JVM arguments used for the daemon process.
+# The setting is particularly useful for tweaking memory settings.
+org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
+# When configured, Gradle will run in incubating parallel mode.
+# This option should only be used with decoupled projects. More details, visit
+# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
+# org.gradle.parallel=true
+# AndroidX package structure to make it clearer which packages are bundled with the
+# Android operating system, and which are packaged with your app's APK
+# https://developer.android.com/topic/libraries/support-library/androidx-rn
+android.useAndroidX=true
+# Kotlin code style for this project: "official" or "obsolete":
+kotlin.code.style=official
+# Enables namespacing of each library's R class so that its R class includes only the
+# resources declared in the library itself and none from the library's dependencies,
+# thereby reducing the size of the R class for that library
+android.nonTransitiveRClass=true

+ 55 - 0
gradle/libs.versions.toml

@@ -0,0 +1,55 @@
+[versions]
+agp = "8.2.2"
+kotlin = "1.9.21"
+core-ktx = "1.12.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 = "2023.10.01"
+ksp = "1.9.21-1.0.16"
+hilt = "2.50"
+hilt-navigation = "1.1.0"
+room = "2.6.1"
+jsoup = "1.17.2"
+media3_version = "1.2.1"
+
+[libraries]
+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" }
+espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso-core" }
+lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle-runtime-ktx" }
+lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle-runtime-ktx" }
+activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activity-compose" }
+compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose-bom" }
+ui = { group = "androidx.compose.ui", name = "ui" }
+ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
+ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
+ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
+ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
+ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
+material3 = { group = "androidx.compose.material3", name = "material3" }
+
+# Hilt
+hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
+hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hilt-navigation" }
+hilt-android-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" }
+
+# Room
+room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
+room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
+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-session = { group = "androidx.media3", name = "media3-session", version.ref = "media3_version" }
+media3-ui = { group = "androidx.media3", name = "media3-ui", version.ref = "media3_version" }
+
+[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" }
+

BIN
gradle/wrapper/gradle-wrapper.jar


この差分においてかなりの量のファイルが変更されているため、一部のファイルを表示していません