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