feat: MVP implementation - Kiosk Browser

- Fullscreen WebView with immersive mode
- URL whitelist system
- Admin PIN-protected settings
- Boot receiver for auto-launch
- Idle timeout with homepage reset
- Lock Task Mode support
- Material Design 3 UI

Team: Pluto (coordinator), Margaret, Spirit
This commit is contained in:
2026-02-03 09:09:20 -06:00
parent 43d91e89ca
commit 6dc1a73d2e
17 changed files with 727 additions and 13 deletions
+10
View File
@@ -0,0 +1,10 @@
*.iml
.gradle
/local.properties
/.idea
.DS_Store
/build
/captures
.externalNativeBuild
.cxx
local.properties
+57 -13
View File
@@ -1,26 +1,70 @@
# Android Kiosk Browser # 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 - **Fullscreen WebView** - Immersive mode, no address bar or browser chrome
- **Kiosk Mode**: Full-screen immersive experience - **URL Whitelist** - Only allow configured domains
- **Admin Interface**: Remote configuration management - **Lock Task Mode** - Disables home/back/recent buttons
- **Offline Support**: Cache pages for offline viewing - **Admin PIN Exit** - PIN-protected escape from kiosk mode
- **Auto-refresh**: Periodic page refresh for digital signage - **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 ## License
TBD MIT License - hobbs.farm
--- ---
*Project initialized: 2026-02-03*
*Project managed in Vikunja | Docs on Wiki*
+53
View File
@@ -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)
}
+67
View File
@@ -0,0 +1,67 @@
<?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.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<application
android:allowBackup="false"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.KioskBrowser"
android:usesCleartextTraffic="true"
tools:targetApi="31">
<!-- Main Kiosk Activity -->
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTask"
android:screenOrientation="landscape"
android:configChanges="orientation|screenSize|keyboard|keyboardHidden"
android:theme="@style/Theme.KioskBrowser.Fullscreen">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.HOME" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
<!-- Admin Settings Activity -->
<activity
android:name=".AdminActivity"
android:exported="false"
android:theme="@style/Theme.KioskBrowser" />
<!-- Boot Receiver - Auto-launch on device start -->
<receiver
android:name=".BootReceiver"
android:exported="true"
android:enabled="true">
<intent-filter android:priority="999">
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.QUICKBOOT_POWERON" />
</intent-filter>
</receiver>
<!-- Device Admin Receiver for Lock Task Mode -->
<receiver
android:name=".KioskDeviceAdminReceiver"
android:exported="true"
android:permission="android.permission.BIND_DEVICE_ADMIN">
<meta-data
android:name="android.app.device_admin"
android:resource="@xml/device_admin" />
<intent-filter>
<action android:name="android.app.action.DEVICE_ADMIN_ENABLED" />
</intent-filter>
</receiver>
</application>
</manifest>
@@ -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()
}
}
@@ -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)
}
}
}
@@ -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)
}
}
@@ -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))
}
}
}
+102
View File
@@ -0,0 +1,102 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="24dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Kiosk Browser Settings"
android:textSize="24sp"
android:textStyle="bold"
android:layout_marginBottom="24dp" />
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="Homepage URL"
android:layout_marginBottom="16dp">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etHomepage"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textUri" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="Allowed Domains (comma-separated)"
android:layout_marginBottom="16dp">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etWhitelist"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="text" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="Idle Timeout (seconds)"
android:layout_marginBottom="16dp">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etIdleTimeout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="number" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="Admin PIN"
android:layout_marginBottom="24dp">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etAdminPin"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="numberPassword" />
</com.google.android.material.textfield.TextInputLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<Button
android:id="@+id/btnSave"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginEnd="8dp"
android:text="Save Settings" />
<Button
android:id="@+id/btnExit"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Back to Kiosk" />
</LinearLayout>
<Button
android:id="@+id/btnExitKiosk"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:backgroundTint="#D32F2F"
android:text="Exit Kiosk Mode" />
</LinearLayout>
</ScrollView>
+19
View File
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<WebView
android:id="@+id/webView"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<!-- Invisible admin tap area (top-left corner) -->
<View
android:id="@+id/adminTapArea"
android:layout_width="80dp"
android:layout_height="80dp"
android:layout_gravity="start|top"
android:onClick="onAdminAreaTap" />
</FrameLayout>
+7
View File
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="primary">#1976D2</color>
<color name="primary_container">#BBDEFB</color>
<color name="white">#FFFFFF</color>
<color name="black">#000000</color>
</resources>
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Kiosk Browser</string>
</resources>
+12
View File
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.KioskBrowser" parent="Theme.Material3.DayNight.NoActionBar">
<item name="colorPrimary">@color/primary</item>
<item name="colorPrimaryContainer">@color/primary_container</item>
</style>
<style name="Theme.KioskBrowser.Fullscreen" parent="Theme.KioskBrowser">
<item name="android:windowFullscreen">true</item>
<item name="android:windowContentOverlay">@null</item>
</style>
</resources>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<device-admin xmlns:android="http://schemas.android.com/apk/res/android">
<uses-policies>
<lock-task />
</uses-policies>
</device-admin>
+5
View File
@@ -0,0 +1,5 @@
// Top-level build file
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.android) apply false
}
+29
View File
@@ -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" }
+17
View File
@@ -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")