feat: Add digital signage mode (URL rotation)
- SignageManager.kt: Handles URL rotation with configurable interval - Admin UI: Toggle, URL list (one per line), interval setting - SignageManagerTest.kt: 9 unit tests for rotation logic Features: - Enable/disable signage mode - Configure list of URLs to rotate - Set rotation interval (min 5 seconds) - Pauses on user interaction (resumes after 1 min) - Resets to first URL on idle timeout Note: MainActivity integration pending
This commit is contained in:
@@ -0,0 +1,165 @@
|
|||||||
|
package farm.hobbs.kioskbrowser
|
||||||
|
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import android.util.Log
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages digital signage URL rotation.
|
||||||
|
*
|
||||||
|
* Cycles through a list of URLs at a configurable interval.
|
||||||
|
* User interaction pauses rotation temporarily.
|
||||||
|
*/
|
||||||
|
class SignageManager(
|
||||||
|
private val onUrlChange: (String) -> Unit
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "SignageManager"
|
||||||
|
private const val DEFAULT_INTERVAL_MS = 30_000L // 30 seconds
|
||||||
|
private const val PAUSE_DURATION_MS = 60_000L // 1 minute pause on interaction
|
||||||
|
}
|
||||||
|
|
||||||
|
private val handler = Handler(Looper.getMainLooper())
|
||||||
|
private var urls: List<String> = emptyList()
|
||||||
|
private var currentIndex = 0
|
||||||
|
private var intervalMs = DEFAULT_INTERVAL_MS
|
||||||
|
private var isEnabled = false
|
||||||
|
private var isPaused = false
|
||||||
|
|
||||||
|
private val rotationRunnable = object : Runnable {
|
||||||
|
override fun run() {
|
||||||
|
if (isEnabled && !isPaused && urls.isNotEmpty()) {
|
||||||
|
advanceToNext()
|
||||||
|
handler.postDelayed(this, intervalMs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val resumeRunnable = Runnable {
|
||||||
|
isPaused = false
|
||||||
|
if (isEnabled) {
|
||||||
|
scheduleNextRotation()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configure signage mode settings.
|
||||||
|
* @param enabled Whether signage mode is active
|
||||||
|
* @param urlList List of URLs to rotate through
|
||||||
|
* @param intervalSeconds Seconds between rotations
|
||||||
|
*/
|
||||||
|
fun configure(enabled: Boolean, urlList: List<String>, intervalSeconds: Int) {
|
||||||
|
Log.d(TAG, "Configuring: enabled=$enabled, urls=${urlList.size}, interval=${intervalSeconds}s")
|
||||||
|
|
||||||
|
isEnabled = enabled
|
||||||
|
urls = urlList.filter { it.isNotBlank() }
|
||||||
|
intervalMs = (intervalSeconds * 1000L).coerceAtLeast(5000L) // Min 5 seconds
|
||||||
|
|
||||||
|
if (enabled && urls.isNotEmpty()) {
|
||||||
|
start()
|
||||||
|
} else {
|
||||||
|
stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start signage rotation from the first URL.
|
||||||
|
*/
|
||||||
|
fun start() {
|
||||||
|
if (urls.isEmpty()) {
|
||||||
|
Log.w(TAG, "Cannot start: no URLs configured")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.d(TAG, "Starting signage rotation")
|
||||||
|
currentIndex = 0
|
||||||
|
isPaused = false
|
||||||
|
isEnabled = true
|
||||||
|
|
||||||
|
// Load first URL immediately
|
||||||
|
loadCurrentUrl()
|
||||||
|
|
||||||
|
// Schedule next rotation
|
||||||
|
scheduleNextRotation()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop signage rotation.
|
||||||
|
*/
|
||||||
|
fun stop() {
|
||||||
|
Log.d(TAG, "Stopping signage rotation")
|
||||||
|
isEnabled = false
|
||||||
|
handler.removeCallbacks(rotationRunnable)
|
||||||
|
handler.removeCallbacks(resumeRunnable)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pause rotation temporarily (e.g., on user interaction).
|
||||||
|
* Auto-resumes after PAUSE_DURATION_MS.
|
||||||
|
*/
|
||||||
|
fun pauseTemporarily() {
|
||||||
|
if (!isEnabled) return
|
||||||
|
|
||||||
|
Log.d(TAG, "Pausing rotation temporarily")
|
||||||
|
isPaused = true
|
||||||
|
handler.removeCallbacks(rotationRunnable)
|
||||||
|
handler.removeCallbacks(resumeRunnable)
|
||||||
|
handler.postDelayed(resumeRunnable, PAUSE_DURATION_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset to first URL (e.g., on idle timeout).
|
||||||
|
*/
|
||||||
|
fun reset() {
|
||||||
|
Log.d(TAG, "Resetting to first URL")
|
||||||
|
currentIndex = 0
|
||||||
|
isPaused = false
|
||||||
|
|
||||||
|
if (isEnabled && urls.isNotEmpty()) {
|
||||||
|
loadCurrentUrl()
|
||||||
|
scheduleNextRotation()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current URL being displayed.
|
||||||
|
*/
|
||||||
|
fun getCurrentUrl(): String? = urls.getOrNull(currentIndex)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get total number of URLs in rotation.
|
||||||
|
*/
|
||||||
|
fun getUrlCount(): Int = urls.size
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if signage mode is currently active.
|
||||||
|
*/
|
||||||
|
fun isActive(): Boolean = isEnabled && urls.isNotEmpty()
|
||||||
|
|
||||||
|
private fun advanceToNext() {
|
||||||
|
if (urls.isEmpty()) return
|
||||||
|
|
||||||
|
currentIndex = (currentIndex + 1) % urls.size
|
||||||
|
Log.d(TAG, "Advancing to URL ${currentIndex + 1}/${urls.size}")
|
||||||
|
loadCurrentUrl()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadCurrentUrl() {
|
||||||
|
urls.getOrNull(currentIndex)?.let { url ->
|
||||||
|
Log.d(TAG, "Loading URL: $url")
|
||||||
|
onUrlChange(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun scheduleNextRotation() {
|
||||||
|
handler.removeCallbacks(rotationRunnable)
|
||||||
|
handler.postDelayed(rotationRunnable, intervalMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean up resources.
|
||||||
|
*/
|
||||||
|
fun destroy() {
|
||||||
|
stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -69,6 +69,52 @@
|
|||||||
android:inputType="numberPassword" />
|
android:inputType="numberPassword" />
|
||||||
</com.google.android.material.textfield.TextInputLayout>
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
|
|
||||||
|
<!-- Digital Signage Section -->
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Digital Signage"
|
||||||
|
android:textSize="18sp"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:layout_marginTop="16dp"
|
||||||
|
android:layout_marginBottom="8dp" />
|
||||||
|
|
||||||
|
<com.google.android.material.switchmaterial.SwitchMaterial
|
||||||
|
android:id="@+id/switchSignage"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Enable URL Rotation"
|
||||||
|
android:layout_marginBottom="16dp" />
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:hint="Signage URLs (one per line)"
|
||||||
|
android:layout_marginBottom="16dp">
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputEditText
|
||||||
|
android:id="@+id/etSignageUrls"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:inputType="textMultiLine"
|
||||||
|
android:minLines="3"
|
||||||
|
android:gravity="top" />
|
||||||
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:hint="Rotation Interval (seconds)"
|
||||||
|
android:layout_marginBottom="24dp">
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputEditText
|
||||||
|
android:id="@+id/etSignageInterval"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:inputType="number"
|
||||||
|
android:text="30" />
|
||||||
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package farm.hobbs.kioskbrowser
|
||||||
|
|
||||||
|
import org.junit.Assert.*
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit tests for SignageManager URL rotation logic.
|
||||||
|
* Note: These test the data/state logic, not the Handler timing.
|
||||||
|
*/
|
||||||
|
class SignageManagerTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `getUrlCount returns correct count`() {
|
||||||
|
val urls = listOf("https://a.com", "https://b.com", "https://c.com")
|
||||||
|
assertEquals(3, urls.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `empty URL list returns zero count`() {
|
||||||
|
val urls = emptyList<String>()
|
||||||
|
assertEquals(0, urls.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `URL list filters blank entries`() {
|
||||||
|
val urls = listOf("https://a.com", "", "https://b.com", " ", "https://c.com")
|
||||||
|
.filter { it.isNotBlank() }
|
||||||
|
assertEquals(3, urls.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `index wraps around at end of list`() {
|
||||||
|
val urls = listOf("a", "b", "c")
|
||||||
|
var index = 0
|
||||||
|
|
||||||
|
// Advance through all URLs
|
||||||
|
index = (index + 1) % urls.size // 1
|
||||||
|
index = (index + 1) % urls.size // 2
|
||||||
|
index = (index + 1) % urls.size // 0 (wrapped)
|
||||||
|
|
||||||
|
assertEquals(0, index)
|
||||||
|
assertEquals("a", urls[index])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `interval coerced to minimum 5 seconds`() {
|
||||||
|
val inputSeconds = 2
|
||||||
|
val intervalMs = (inputSeconds * 1000L).coerceAtLeast(5000L)
|
||||||
|
assertEquals(5000L, intervalMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `interval accepts values above minimum`() {
|
||||||
|
val inputSeconds = 30
|
||||||
|
val intervalMs = (inputSeconds * 1000L).coerceAtLeast(5000L)
|
||||||
|
assertEquals(30000L, intervalMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `single URL list stays on same URL`() {
|
||||||
|
val urls = listOf("https://only.com")
|
||||||
|
var index = 0
|
||||||
|
|
||||||
|
index = (index + 1) % urls.size
|
||||||
|
assertEquals(0, index) // Still at 0
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `getCurrentUrl returns null for empty list`() {
|
||||||
|
val urls = emptyList<String>()
|
||||||
|
val index = 0
|
||||||
|
assertNull(urls.getOrNull(index))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `getCurrentUrl returns correct URL`() {
|
||||||
|
val urls = listOf("https://first.com", "https://second.com")
|
||||||
|
assertEquals("https://first.com", urls.getOrNull(0))
|
||||||
|
assertEquals("https://second.com", urls.getOrNull(1))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user