screentinker/android/app/src/main/java/com/remotedisplay/player/MainActivity.kt
ScreenTinker 1594a9d4a4 Initial open source release
ScreenTinker - open source digital signage management software.
MIT License, all features included, no license gates.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 12:14:53 -05:00

554 lines
22 KiB
Kotlin

package com.remotedisplay.player
import android.accessibilityservice.AccessibilityServiceInfo
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.os.Build
import android.os.Bundle
import android.widget.FrameLayout
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import android.util.Log
import android.view.KeyEvent
import android.view.View
import android.view.WindowManager
import android.view.accessibility.AccessibilityManager
import android.widget.ImageView
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
import androidx.media3.ui.PlayerView
import com.remotedisplay.player.data.ContentCache
import com.remotedisplay.player.data.ServerConfig
import com.remotedisplay.player.player.MediaPlayerManager
import com.remotedisplay.player.player.PlaylistController
import com.remotedisplay.player.player.PlaylistItem
import com.remotedisplay.player.player.ZoneManager
import com.remotedisplay.player.remote.ScreenshotCapture
import com.remotedisplay.player.remote.TouchInjector
import com.remotedisplay.player.service.UpdateChecker
import com.remotedisplay.player.service.WebSocketService
import org.json.JSONObject
import kotlin.concurrent.thread
class MainActivity : AppCompatActivity() {
private lateinit var config: ServerConfig
private lateinit var contentCache: ContentCache
private lateinit var screenshotCapture: ScreenshotCapture
private lateinit var touchInjector: TouchInjector
private var wsService: WebSocketService? = null
private var bound = false
private lateinit var mediaPlayer: MediaPlayerManager
private lateinit var playlistController: PlaylistController
private lateinit var updateChecker: UpdateChecker
private var zoneManager: ZoneManager? = null
private lateinit var playerView: PlayerView
private lateinit var imageView: ImageView
private lateinit var statusOverlay: View
private lateinit var statusText: TextView
private lateinit var rootView: View
private val handler = Handler(Looper.getMainLooper())
private var remoteStreaming = false
private var screenshotStreamRunnable: Runnable? = null
private var playbackStarted = false
private val connection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
val binder = service as WebSocketService.LocalBinder
wsService = binder.getService()
bound = true
setupServiceCallbacks()
wsService?.connect()
}
override fun onServiceDisconnected(name: ComponentName?) {
wsService = null
bound = false
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
config = ServerConfig(this)
val prefs = getSharedPreferences("remote_display", MODE_PRIVATE)
// Show setup wizard if not completed yet
if (!prefs.getBoolean("setup_complete", false)) {
// Auto-mark complete if accessibility is already enabled (existing install)
if (isAccessibilityEnabled()) {
prefs.edit().putBoolean("setup_complete", true).apply()
} else {
startActivity(Intent(this, SetupActivity::class.java))
finish()
return
}
}
// Check provisioning BEFORE inflating the heavy media layout
if (!config.isProvisioned || !config.isPaired) {
startActivity(Intent(this, ProvisioningActivity::class.java))
finish()
return
}
setContentView(R.layout.activity_main)
// Fullscreen immersive
@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
)
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
contentCache = ContentCache(this)
screenshotCapture = ScreenshotCapture()
touchInjector = TouchInjector()
playerView = findViewById(R.id.playerView)
imageView = findViewById(R.id.imageView)
statusOverlay = findViewById(R.id.statusOverlay)
statusText = findViewById(R.id.statusText)
rootView = findViewById(R.id.rootLayout)
// Hide player controls
playerView.useController = false
// Setup zone manager for multi-zone layouts
zoneManager = ZoneManager(this, rootView as FrameLayout) {
playlistController.onVideoComplete()
}
// Setup playlist controller
playlistController = PlaylistController(
onItemChanged = { item -> item?.let { playItem(it) } },
onPlaylistEmpty = { showStatus("Waiting for content...") },
onRequestRefresh = { wsService?.requestPlaylistRefresh() }
)
// Setup media player
val youtubeWebView = findViewById<android.webkit.WebView>(R.id.youtubeWebView)
mediaPlayer = MediaPlayerManager(
context = this,
playerView = playerView,
imageView = imageView,
youtubeWebView = youtubeWebView,
onVideoComplete = { playlistController.onVideoComplete() }
)
showStatus("Connecting to server...")
// Start and bind to WebSocket service
try {
val serviceIntent = Intent(this, WebSocketService::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
startForegroundService(serviceIntent)
} else {
startService(serviceIntent)
}
bindService(serviceIntent, connection, Context.BIND_AUTO_CREATE)
} catch (e: Exception) {
Log.e("MainActivity", "Failed to start service: ${e.message}")
showStatus("Service error: ${e.message}")
}
// Start auto-update checker
updateChecker = UpdateChecker(this)
updateChecker.startPeriodicCheck()
}
private fun setupServiceCallbacks() {
wsService?.onPlaylistUpdate = { data ->
try {
// Check if device is suspended (trial expired / over limit)
if (data.optBoolean("suspended", false)) {
val message = data.optString("message", "Account Suspended")
val detail = data.optString("detail", "Please upgrade your plan.")
handler.post {
showStatus("$message\n$detail")
if (::mediaPlayer.isInitialized) mediaPlayer.stop()
}
} else {
val assignments = data.getJSONArray("assignments")
// Check for multi-zone layout
val layoutObj = if (data.isNull("layout")) null else data.optJSONObject("layout")
val layoutZones = layoutObj?.optJSONArray("zones")
if (layoutZones != null && layoutZones.length() > 1) {
// Multi-zone mode - use ZoneManager
val layoutId = layoutObj?.optString("id", "") ?: ""
val currentLayoutId = zoneManager?.currentLayoutId
// Build a signature of current assignments to detect content changes
val assignmentSig = (0 until assignments.length()).map { i ->
val a = assignments.getJSONObject(i)
"${a.optString("content_id")}:${a.optString("zone_id")}:${a.optString("widget_id")}"
}.sorted().joinToString("|")
val changed = assignmentSig != zoneManager?.lastAssignmentSig
if (zoneManager?.hasZones() != true || layoutId != currentLayoutId) {
Log.i("MainActivity", "Multi-zone layout with ${layoutZones.length()} zones (layout=$layoutId, was=$currentLayoutId)")
handler.post {
hideStatus()
if (::mediaPlayer.isInitialized) mediaPlayer.stop()
playlistController.stop()
playerView.visibility = View.GONE
imageView.visibility = View.GONE
zoneManager?.setupZones(layoutZones, layoutId)
zoneManager?.renderAssignments(assignments, config.serverUrl, contentCache)
zoneManager?.lastAssignmentSig = assignmentSig
}
} else if (changed) {
Log.i("MainActivity", "Multi-zone assignments changed, re-rendering")
handler.post {
zoneManager?.renderAssignments(assignments, config.serverUrl, contentCache)
zoneManager?.lastAssignmentSig = assignmentSig
}
} else {
Log.i("MainActivity", "Multi-zone unchanged, skipping")
}
} else {
// Single-zone mode - use PlaylistController (existing behavior)
if (zoneManager?.hasZones() == true) handler.post { zoneManager?.cleanup() }
playlistController.updatePlaylist(assignments)
}
// Download any missing local content (skip remote URLs)
thread {
for (i in 0 until assignments.length()) {
val item = assignments.getJSONObject(i)
val contentId = item.getString("content_id")
val filename = item.optString("filename", "content")
val remoteUrl = item.optString("remote_url", null)
// Skip remote URL content - it streams directly
if (!remoteUrl.isNullOrEmpty()) {
wsService?.sendContentAck(contentId, "ready")
continue
}
if (!contentCache.isContentCached(contentId)) {
Log.i("MainActivity", "Downloading content: $filename")
var downloaded = false
for (attempt in 1..3) {
val file = contentCache.downloadContent(config.serverUrl, contentId, filename)
if (file != null) {
wsService?.sendContentAck(contentId, "ready")
downloaded = true
break
}
Log.w("MainActivity", "Download attempt $attempt failed for $filename")
if (attempt < 3) Thread.sleep(2000L * attempt)
}
if (!downloaded) wsService?.sendContentAck(contentId, "failed")
}
}
// Start or resume playback after downloads complete
handler.post {
playlistController.startIfNeeded()
}
}
} // end else (not suspended)
} catch (e: Exception) {
Log.e("MainActivity", "Playlist update error: ${e.message}")
}
}
wsService?.onContentDelete = { contentId ->
contentCache.deleteContent(contentId)
playlistController.removeContent(contentId)
}
wsService?.onScreenshotRequest = {
// Handled by service now
}
wsService?.onRemoteStart = {
// Handled by service now
}
// Provide screenshot callback to service (composite capture on main thread)
wsService?.onCaptureScreenshot = {
screenshotCapture.captureView(rootView, 40)
}
wsService?.onRemoteStop = {
remoteStreaming = false
stopScreenshotStreaming()
}
wsService?.onRemoteTouch = { x, y, action ->
when (action) {
"tap" -> touchInjector.injectTap(rootView, x, y)
"down" -> touchInjector.injectDown(rootView, x, y)
"move" -> touchInjector.injectMove(rootView, x, y)
"up" -> touchInjector.injectUp(rootView, x, y)
}
}
wsService?.onRemoteKey = { _ ->
// Key injection handled in WebSocketService directly
}
wsService?.onCommand = { type, payload ->
Log.i("MainActivity", "Command received: $type")
when (type) {
"reboot", "shutdown", "power_menu" -> {
val svc = com.remotedisplay.player.service.PowerAccessibilityService.instance
if (svc != null) {
svc.showPowerDialog()
Log.i("MainActivity", "Power dialog shown via accessibility")
} else {
Log.w("MainActivity", "Accessibility service not enabled - trying fallback")
thread {
try { Runtime.getRuntime().exec(arrayOf("input", "keyevent", "--longpress", "26")).waitFor() } catch (_: Exception) {}
}
}
}
"screen_off" -> {
thread {
try {
Runtime.getRuntime().exec(arrayOf("input", "keyevent", "26")).waitFor() // POWER key
} catch (e: Exception) {
Log.e("MainActivity", "Screen off failed: ${e.message}")
}
}
}
"screen_on" -> {
thread {
try {
Runtime.getRuntime().exec(arrayOf("input", "keyevent", "224")).waitFor() // WAKEUP key
} catch (e: Exception) {
Log.e("MainActivity", "Screen on failed: ${e.message}")
}
}
}
"launch" -> {
val intent = android.content.Intent(this@MainActivity, MainActivity::class.java).apply {
addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK or android.content.Intent.FLAG_ACTIVITY_CLEAR_TOP)
}
startActivity(intent)
}
"update" -> {
Log.i("MainActivity", "Force update check triggered")
if (::updateChecker.isInitialized) updateChecker.checkForUpdate()
}
"refresh" -> {
wsService?.connect()
}
}
}
wsService?.onRegistered = { _ ->
hideStatus()
}
wsService?.onUnpaired = {
Log.w("MainActivity", "Device removed from server, going to provisioning")
handler.post {
startActivity(Intent(this, ProvisioningActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK)
})
finish()
}
}
}
private fun playItem(item: PlaylistItem) {
hideStatus()
// YouTube content - play in WebView
if (item.mimeType == "video/youtube" && !item.remoteUrl.isNullOrEmpty()) {
Log.i("MainActivity", "Playing YouTube: ${item.remoteUrl}")
mediaPlayer.playYoutube(item.remoteUrl!!, item.durationSec)
wsService?.sendPlaybackState(item.contentId, 0f)
return
}
// Remote URL content - stream directly, no download
if (item.isRemote) {
Log.i("MainActivity", "Playing remote content: ${item.remoteUrl}")
if (item.mimeType.startsWith("video/")) {
mediaPlayer.playVideoFromUrl(item.remoteUrl!!, item.muted)
} else if (item.mimeType.startsWith("image/")) {
mediaPlayer.showImageFromUrl(item.remoteUrl!!)
}
wsService?.sendPlaybackState(item.contentId, 0f)
return
}
// Local content - download if not cached
val file = contentCache.getCachedFile(item.contentId)
if (file == null) {
Log.w("MainActivity", "Content not cached: ${item.contentId}, downloading...")
showStatus("Downloading ${item.filename}...")
thread {
val downloaded = contentCache.downloadContent(config.serverUrl, item.contentId, item.filename)
handler.post {
if (downloaded != null) {
playFile(item, downloaded)
} else {
showStatus("Download failed: ${item.filename}")
handler.postDelayed({ playlistController.next() }, 3000)
}
}
}
return
}
playFile(item, file)
}
private fun playFile(item: PlaylistItem, file: java.io.File) {
if (item.mimeType.startsWith("video/")) {
mediaPlayer.playVideo(file, item.muted)
} else if (item.mimeType.startsWith("image/")) {
mediaPlayer.showImage(file)
}
// Report playback state
wsService?.sendPlaybackState(item.contentId, 0f)
}
private fun showStatus(message: String) {
statusOverlay.visibility = View.VISIBLE
statusText.text = message
}
private fun hideStatus() {
statusOverlay.visibility = View.GONE
}
private fun captureAndSendScreenshot() {
Log.i("MainActivity", "Capturing screenshot")
val base64 = screenshotCapture.captureView(rootView, 40)
if (base64 != null) {
Log.i("MainActivity", "Screenshot captured, size=${base64.length} chars, sending...")
wsService?.sendScreenshot(base64)
} else {
Log.e("MainActivity", "Screenshot capture returned null!")
}
}
private fun startScreenshotStreaming() {
stopScreenshotStreaming()
screenshotStreamRunnable = object : Runnable {
override fun run() {
if (remoteStreaming) {
captureAndSendScreenshot()
handler.postDelayed(this, 1000) // ~1 FPS
}
}
}
handler.post(screenshotStreamRunnable!!)
}
private fun stopScreenshotStreaming() {
screenshotStreamRunnable?.let { handler.removeCallbacks(it) }
screenshotStreamRunnable = null
}
private fun handleRemoteKey(keycode: String) {
// Use shell `input keyevent` for system keys (HOME, BACK, etc.)
// This works from the app process on most Android TV devices
thread {
try {
val code = when (keycode) {
"KEYCODE_HOME" -> "3"
"KEYCODE_BACK" -> "4"
"KEYCODE_MENU" -> "82"
"KEYCODE_VOLUME_UP" -> "24"
"KEYCODE_VOLUME_DOWN" -> "25"
"KEYCODE_DPAD_UP" -> "19"
"KEYCODE_DPAD_DOWN" -> "20"
"KEYCODE_DPAD_LEFT" -> "21"
"KEYCODE_DPAD_RIGHT" -> "22"
"KEYCODE_DPAD_CENTER" -> "23"
"KEYCODE_ENTER" -> "66"
"KEYCODE_POWER" -> "26"
else -> return@thread
}
Log.i("MainActivity", "Injecting key: $keycode ($code)")
val process = Runtime.getRuntime().exec(arrayOf("input", "keyevent", code))
process.waitFor()
Log.i("MainActivity", "Key injection result: ${process.exitValue()}")
} catch (e: Exception) {
Log.e("MainActivity", "Key injection failed: ${e.message}")
}
}
}
@Suppress("DEPRECATION")
override fun onBackPressed() {
// Don't exit the app on back press - this is a kiosk/signage app
Log.i("MainActivity", "Back press intercepted (kiosk mode)")
}
private fun isAccessibilityEnabled(): Boolean {
val am = getSystemService(Context.ACCESSIBILITY_SERVICE) as AccessibilityManager
val myComponent = ComponentName(this, com.remotedisplay.player.service.PowerAccessibilityService::class.java)
return am.getEnabledAccessibilityServiceList(AccessibilityServiceInfo.FEEDBACK_ALL_MASK).any {
it.resolveInfo.serviceInfo.let { si -> ComponentName(si.packageName, si.name) == myComponent }
}
}
override fun onNewIntent(intent: Intent?) {
super.onNewIntent(intent)
// Home press brings us back - just re-apply immersive mode
Log.i("MainActivity", "onNewIntent - returning to foreground")
@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
)
}
override fun onDestroy() {
remoteStreaming = false
zoneManager?.cleanup()
if (::mediaPlayer.isInitialized) {
stopScreenshotStreaming()
mediaPlayer.release()
}
if (bound) {
try { unbindService(connection) } catch (_: Exception) {}
bound = false
}
super.onDestroy()
}
override fun onWindowFocusChanged(hasFocus: Boolean) {
super.onWindowFocusChanged(hasFocus)
if (hasFocus) {
@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
)
}
}
}