diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..10cfdbf
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,10 @@
+*.iml
+.gradle
+/local.properties
+/.idea
+.DS_Store
+/build
+/captures
+.externalNativeBuild
+.cxx
+local.properties
diff --git a/README.md b/README.md
index 9345437..9f9ca03 100644
--- a/README.md
+++ b/README.md
@@ -1,26 +1,70 @@
# Android Kiosk Browser
-A customizable Android browser application designed for kiosk and digital signage deployments.
+A customizable locked-down browser for Android kiosk and digital signage deployments.
-## Features (Planned)
+## Features (MVP)
-- **Restricted Browsing**: Lock to specific URLs or domains
-- **Kiosk Mode**: Full-screen immersive experience
-- **Admin Interface**: Remote configuration management
-- **Offline Support**: Cache pages for offline viewing
-- **Auto-refresh**: Periodic page refresh for digital signage
+- **Fullscreen WebView** - Immersive mode, no address bar or browser chrome
+- **URL Whitelist** - Only allow configured domains
+- **Lock Task Mode** - Disables home/back/recent buttons
+- **Admin PIN Exit** - PIN-protected escape from kiosk mode
+- **Auto-launch on Boot** - Starts automatically when device powers on
+- **Idle Timeout** - Returns to homepage after inactivity
+- **Admin Settings** - Configure whitelist, homepage, timeout, PIN
-## Project Status
+## Tech Stack
-🚧 **In Development**
+- Kotlin
+- Android WebView
+- Lock Task Mode (API 21+)
+- DataStore Preferences
+- Material Design 3
-## Getting Started
+## Requirements
-TBD
+- Android 5.0+ (API 21)
+- For Lock Task Mode: Device Owner provisioning
+
+## Building
+
+```bash
+./gradlew assembleDebug
+```
+
+## Device Setup
+
+To enable Lock Task Mode (full kiosk lockdown):
+
+```bash
+adb shell dpm set-device-owner farm.hobbs.kioskbrowser/.KioskDeviceAdminReceiver
+```
+
+## Admin Access
+
+Triple-tap the top-left corner of the screen to access admin settings.
+
+Default PIN: `1234`
+
+## Configuration
+
+Settings available in Admin panel:
+- Homepage URL
+- Allowed domains (comma-separated)
+- Idle timeout (seconds)
+- Admin PIN
+
+## Future Enhancements
+
+- [ ] Remote configuration
+- [ ] Multiple profiles
+- [ ] Usage analytics
+- [ ] Digital signage mode (URL rotation)
+- [ ] Session data clearing on idle
## License
-TBD
+MIT License - hobbs.farm
---
-*Project initialized: 2026-02-03*
\ No newline at end of file
+
+*Project managed in Vikunja | Docs on Wiki*
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
new file mode 100644
index 0000000..26252c7
--- /dev/null
+++ b/app/build.gradle.kts
@@ -0,0 +1,53 @@
+plugins {
+ alias(libs.plugins.android.application)
+ alias(libs.plugins.kotlin.android)
+}
+
+android {
+ namespace = "farm.hobbs.kioskbrowser"
+ compileSdk = 34
+
+ defaultConfig {
+ applicationId = "farm.hobbs.kioskbrowser"
+ minSdk = 21
+ targetSdk = 34
+ versionCode = 1
+ versionName = "1.0.0-mvp"
+
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = true
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro"
+ )
+ }
+ }
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+ kotlinOptions {
+ jvmTarget = "17"
+ }
+ buildFeatures {
+ viewBinding = true
+ }
+}
+
+dependencies {
+ implementation(libs.androidx.core.ktx)
+ implementation(libs.androidx.appcompat)
+ implementation(libs.material)
+ implementation(libs.androidx.activity)
+ implementation(libs.androidx.constraintlayout)
+ implementation(libs.androidx.webkit)
+ implementation(libs.androidx.datastore.preferences)
+
+ testImplementation(libs.junit)
+ androidTestImplementation(libs.androidx.junit)
+ androidTestImplementation(libs.androidx.espresso.core)
+}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..e3ca4b7
--- /dev/null
+++ b/app/src/main/AndroidManifest.xml
@@ -0,0 +1,67 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/kotlin/farm/hobbs/kioskbrowser/AdminActivity.kt b/app/src/main/kotlin/farm/hobbs/kioskbrowser/AdminActivity.kt
new file mode 100644
index 0000000..d55068b
--- /dev/null
+++ b/app/src/main/kotlin/farm/hobbs/kioskbrowser/AdminActivity.kt
@@ -0,0 +1,106 @@
+package farm.hobbs.kioskbrowser
+
+import android.os.Bundle
+import android.widget.Toast
+import androidx.appcompat.app.AlertDialog
+import androidx.appcompat.app.AppCompatActivity
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.stringPreferencesKey
+import farm.hobbs.kioskbrowser.databinding.ActivityAdminBinding
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.runBlocking
+import android.text.InputType
+import android.widget.EditText
+
+class AdminActivity : AppCompatActivity() {
+
+ private lateinit var binding: ActivityAdminBinding
+ private val adminPin = "1234" // Default PIN - should be configurable
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ // Require PIN before showing settings
+ showPinDialog()
+ }
+
+ private fun showPinDialog() {
+ val input = EditText(this).apply {
+ inputType = InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_VARIATION_PASSWORD
+ hint = "Enter Admin PIN"
+ }
+
+ AlertDialog.Builder(this)
+ .setTitle("Admin Access")
+ .setMessage("Enter PIN to access settings")
+ .setView(input)
+ .setPositiveButton("OK") { _, _ ->
+ if (input.text.toString() == getStoredPin()) {
+ setupUI()
+ } else {
+ Toast.makeText(this, "Invalid PIN", Toast.LENGTH_SHORT).show()
+ finish()
+ }
+ }
+ .setNegativeButton("Cancel") { _, _ -> finish() }
+ .setCancelable(false)
+ .show()
+ }
+
+ private fun getStoredPin(): String {
+ return runBlocking {
+ dataStore.data.first()[stringPreferencesKey("admin_pin")] ?: adminPin
+ }
+ }
+
+ private fun setupUI() {
+ binding = ActivityAdminBinding.inflate(layoutInflater)
+ setContentView(binding.root)
+
+ loadCurrentSettings()
+
+ binding.btnSave.setOnClickListener {
+ saveSettings()
+ }
+
+ binding.btnExit.setOnClickListener {
+ finish()
+ }
+
+ binding.btnExitKiosk.setOnClickListener {
+ // Exit kiosk mode completely
+ finishAffinity()
+ }
+ }
+
+ private fun loadCurrentSettings() {
+ runBlocking {
+ dataStore.data.first().let { prefs ->
+ binding.etHomepage.setText(
+ prefs[stringPreferencesKey("homepage")] ?: "https://hobbs.farm"
+ )
+ binding.etWhitelist.setText(
+ prefs[stringPreferencesKey("whitelist")] ?: "hobbs.farm"
+ )
+ binding.etIdleTimeout.setText(
+ prefs[stringPreferencesKey("idle_timeout")] ?: "300"
+ )
+ binding.etAdminPin.setText(
+ prefs[stringPreferencesKey("admin_pin")] ?: "1234"
+ )
+ }
+ }
+ }
+
+ private fun saveSettings() {
+ runBlocking {
+ dataStore.edit { prefs ->
+ prefs[stringPreferencesKey("homepage")] = binding.etHomepage.text.toString()
+ prefs[stringPreferencesKey("whitelist")] = binding.etWhitelist.text.toString()
+ prefs[stringPreferencesKey("idle_timeout")] = binding.etIdleTimeout.text.toString()
+ prefs[stringPreferencesKey("admin_pin")] = binding.etAdminPin.text.toString()
+ }
+ }
+ Toast.makeText(this, "Settings saved", Toast.LENGTH_SHORT).show()
+ }
+}
diff --git a/app/src/main/kotlin/farm/hobbs/kioskbrowser/BootReceiver.kt b/app/src/main/kotlin/farm/hobbs/kioskbrowser/BootReceiver.kt
new file mode 100644
index 0000000..cf1b67e
--- /dev/null
+++ b/app/src/main/kotlin/farm/hobbs/kioskbrowser/BootReceiver.kt
@@ -0,0 +1,19 @@
+package farm.hobbs.kioskbrowser
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+
+class BootReceiver : BroadcastReceiver() {
+ override fun onReceive(context: Context, intent: Intent) {
+ if (intent.action == Intent.ACTION_BOOT_COMPLETED ||
+ intent.action == "android.intent.action.QUICKBOOT_POWERON") {
+
+ val launchIntent = Intent(context, MainActivity::class.java).apply {
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
+ }
+ context.startActivity(launchIntent)
+ }
+ }
+}
diff --git a/app/src/main/kotlin/farm/hobbs/kioskbrowser/KioskDeviceAdminReceiver.kt b/app/src/main/kotlin/farm/hobbs/kioskbrowser/KioskDeviceAdminReceiver.kt
new file mode 100644
index 0000000..27321c4
--- /dev/null
+++ b/app/src/main/kotlin/farm/hobbs/kioskbrowser/KioskDeviceAdminReceiver.kt
@@ -0,0 +1,15 @@
+package farm.hobbs.kioskbrowser
+
+import android.app.admin.DeviceAdminReceiver
+import android.content.Context
+import android.content.Intent
+
+class KioskDeviceAdminReceiver : DeviceAdminReceiver() {
+ override fun onEnabled(context: Context, intent: Intent) {
+ super.onEnabled(context, intent)
+ }
+
+ override fun onDisabled(context: Context, intent: Intent) {
+ super.onDisabled(context, intent)
+ }
+}
diff --git a/app/src/main/kotlin/farm/hobbs/kioskbrowser/MainActivity.kt b/app/src/main/kotlin/farm/hobbs/kioskbrowser/MainActivity.kt
new file mode 100644
index 0000000..c9ec3a8
--- /dev/null
+++ b/app/src/main/kotlin/farm/hobbs/kioskbrowser/MainActivity.kt
@@ -0,0 +1,199 @@
+package farm.hobbs.kioskbrowser
+
+import android.annotation.SuppressLint
+import android.app.Activity
+import android.app.admin.DevicePolicyManager
+import android.content.ComponentName
+import android.content.Context
+import android.content.Intent
+import android.os.Build
+import android.os.Bundle
+import android.os.Handler
+import android.os.Looper
+import android.view.KeyEvent
+import android.view.View
+import android.view.WindowInsets
+import android.view.WindowInsetsController
+import android.view.WindowManager
+import android.webkit.WebChromeClient
+import android.webkit.WebResourceRequest
+import android.webkit.WebView
+import android.webkit.WebViewClient
+import android.widget.Toast
+import androidx.appcompat.app.AppCompatActivity
+import androidx.datastore.preferences.core.stringPreferencesKey
+import androidx.datastore.preferences.preferencesDataStore
+import farm.hobbs.kioskbrowser.databinding.ActivityMainBinding
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.runBlocking
+
+val Context.dataStore by preferencesDataStore(name = "kiosk_settings")
+
+class MainActivity : AppCompatActivity() {
+
+ private lateinit var binding: ActivityMainBinding
+ private lateinit var webView: WebView
+
+ private val idleHandler = Handler(Looper.getMainLooper())
+ private var idleTimeoutMs = 300_000L // 5 minutes default
+ private var homepage = "https://hobbs.farm"
+ private var whitelist = listOf("hobbs.farm")
+
+ private val idleRunnable = Runnable {
+ loadHomepage()
+ clearSession()
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ binding = ActivityMainBinding.inflate(layoutInflater)
+ setContentView(binding.root)
+
+ setupFullscreen()
+ loadSettings()
+ setupWebView()
+ loadHomepage()
+
+ // Keep screen on
+ window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
+ }
+
+ private fun setupFullscreen() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ window.setDecorFitsSystemWindows(false)
+ window.insetsController?.let {
+ it.hide(WindowInsets.Type.statusBars() or WindowInsets.Type.navigationBars())
+ it.systemBarsBehavior = WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
+ }
+ } else {
+ @Suppress("DEPRECATION")
+ window.decorView.systemUiVisibility = (
+ View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
+ or View.SYSTEM_UI_FLAG_FULLSCREEN
+ or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
+ or View.SYSTEM_UI_FLAG_LAYOUT_STABLE
+ or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
+ or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
+ )
+ }
+ }
+
+ private fun loadSettings() {
+ // Load from DataStore
+ runBlocking {
+ dataStore.data.first().let { prefs ->
+ homepage = prefs[stringPreferencesKey("homepage")] ?: homepage
+ whitelist = (prefs[stringPreferencesKey("whitelist")] ?: "hobbs.farm")
+ .split(",").map { it.trim() }
+ idleTimeoutMs = (prefs[stringPreferencesKey("idle_timeout")]?.toLongOrNull()
+ ?: 300) * 1000L
+ }
+ }
+ }
+
+ @SuppressLint("SetJavaScriptEnabled")
+ private fun setupWebView() {
+ webView = binding.webView
+
+ webView.settings.apply {
+ javaScriptEnabled = true
+ domStorageEnabled = true
+ loadWithOverviewMode = true
+ useWideViewPort = true
+ setSupportZoom(true)
+ builtInZoomControls = true
+ displayZoomControls = false
+ }
+
+ webView.webViewClient = object : WebViewClient() {
+ override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {
+ val url = request.url.toString()
+ return if (isUrlAllowed(url)) {
+ resetIdleTimer()
+ false // Allow loading
+ } else {
+ Toast.makeText(this@MainActivity, "URL blocked", Toast.LENGTH_SHORT).show()
+ true // Block and don't load
+ }
+ }
+
+ override fun onPageFinished(view: WebView?, url: String?) {
+ super.onPageFinished(view, url)
+ resetIdleTimer()
+ }
+ }
+
+ webView.webChromeClient = WebChromeClient()
+ }
+
+ private fun isUrlAllowed(url: String): Boolean {
+ return whitelist.any { domain ->
+ url.contains(domain, ignoreCase = true)
+ }
+ }
+
+ private fun loadHomepage() {
+ webView.loadUrl(homepage)
+ }
+
+ private fun clearSession() {
+ webView.clearCache(true)
+ webView.clearHistory()
+ android.webkit.CookieManager.getInstance().removeAllCookies(null)
+ }
+
+ private fun resetIdleTimer() {
+ idleHandler.removeCallbacks(idleRunnable)
+ idleHandler.postDelayed(idleRunnable, idleTimeoutMs)
+ }
+
+ override fun onUserInteraction() {
+ super.onUserInteraction()
+ resetIdleTimer()
+ }
+
+ override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
+ // Block back button - require admin PIN to exit
+ if (keyCode == KeyEvent.KEYCODE_BACK) {
+ if (webView.canGoBack()) {
+ webView.goBack()
+ }
+ return true
+ }
+ return super.onKeyDown(keyCode, event)
+ }
+
+ override fun onWindowFocusChanged(hasFocus: Boolean) {
+ super.onWindowFocusChanged(hasFocus)
+ if (hasFocus) {
+ setupFullscreen()
+ }
+ }
+
+ override fun onResume() {
+ super.onResume()
+ resetIdleTimer()
+ setupFullscreen()
+ }
+
+ override fun onPause() {
+ super.onPause()
+ idleHandler.removeCallbacks(idleRunnable)
+ }
+
+ // Secret admin gesture: triple-tap top-left corner to open settings
+ private var tapCount = 0
+ private val tapHandler = Handler(Looper.getMainLooper())
+
+ fun onAdminAreaTap() {
+ tapCount++
+ tapHandler.removeCallbacksAndMessages(null)
+ tapHandler.postDelayed({ tapCount = 0 }, 500)
+
+ if (tapCount >= 3) {
+ tapCount = 0
+ startActivity(Intent(this, AdminActivity::class.java))
+ }
+ }
+}
diff --git a/app/src/main/res/layout/activity_admin.xml b/app/src/main/res/layout/activity_admin.xml
new file mode 100644
index 0000000..7bf009c
--- /dev/null
+++ b/app/src/main/res/layout/activity_admin.xml
@@ -0,0 +1,102 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml
new file mode 100644
index 0000000..42576c4
--- /dev/null
+++ b/app/src/main/res/layout/activity_main.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
new file mode 100644
index 0000000..d1c89f5
--- /dev/null
+++ b/app/src/main/res/values/colors.xml
@@ -0,0 +1,7 @@
+
+
+ #1976D2
+ #BBDEFB
+ #FFFFFF
+ #000000
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
new file mode 100644
index 0000000..12dd7db
--- /dev/null
+++ b/app/src/main/res/values/strings.xml
@@ -0,0 +1,4 @@
+
+
+ Kiosk Browser
+
diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml
new file mode 100644
index 0000000..3396649
--- /dev/null
+++ b/app/src/main/res/values/themes.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/xml/device_admin.xml b/app/src/main/res/xml/device_admin.xml
new file mode 100644
index 0000000..efa2dc2
--- /dev/null
+++ b/app/src/main/res/xml/device_admin.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/build.gradle.kts b/build.gradle.kts
new file mode 100644
index 0000000..82fbdc4
--- /dev/null
+++ b/build.gradle.kts
@@ -0,0 +1,5 @@
+// Top-level build file
+plugins {
+ alias(libs.plugins.android.application) apply false
+ alias(libs.plugins.kotlin.android) apply false
+}
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
new file mode 100644
index 0000000..653a292
--- /dev/null
+++ b/gradle/libs.versions.toml
@@ -0,0 +1,29 @@
+[versions]
+agp = "8.2.2"
+kotlin = "1.9.22"
+coreKtx = "1.12.0"
+junit = "4.13.2"
+junitVersion = "1.1.5"
+espressoCore = "3.5.1"
+appcompat = "1.6.1"
+material = "1.11.0"
+activity = "1.8.2"
+constraintlayout = "2.1.4"
+webkit = "1.10.0"
+datastorePreferences = "1.0.0"
+
+[libraries]
+androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
+junit = { group = "junit", name = "junit", version.ref = "junit" }
+androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
+androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
+androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
+material = { group = "com.google.android.material", name = "material", version.ref = "material" }
+androidx-activity = { group = "androidx.activity", name = "activity", version.ref = "activity" }
+androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" }
+androidx-webkit = { group = "androidx.webkit", name = "webkit", version.ref = "webkit" }
+androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastorePreferences" }
+
+[plugins]
+android-application = { id = "com.android.application", version.ref = "agp" }
+kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
diff --git a/settings.gradle.kts b/settings.gradle.kts
new file mode 100644
index 0000000..9461097
--- /dev/null
+++ b/settings.gradle.kts
@@ -0,0 +1,17 @@
+pluginManagement {
+ repositories {
+ google()
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+rootProject.name = "KioskBrowser"
+include(":app")