Skip to content

Commit 8d32f5f

Browse files
Merge pull request #43 from creightonlinza/android-release-fixes
Android release fixes
2 parents 9eb9ea3 + 49828ce commit 8d32f5f

File tree

4 files changed

+102
-35
lines changed

4 files changed

+102
-35
lines changed

android/app/src/main/java/com/foreverjukebox/app/MainActivity.kt

Lines changed: 60 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,16 @@ import androidx.activity.compose.setContent
1010
import androidx.activity.OnBackPressedCallback
1111
import androidx.activity.viewModels
1212
import androidx.fragment.app.FragmentActivity
13+
import androidx.lifecycle.Lifecycle
14+
import androidx.lifecycle.lifecycleScope
15+
import androidx.lifecycle.repeatOnLifecycle
16+
import com.foreverjukebox.app.data.AppMode
1317
import com.foreverjukebox.app.ui.ForeverJukeboxApp
1418
import com.foreverjukebox.app.ui.MainViewModel
1519
import com.google.android.gms.cast.framework.CastContext
1620
import com.google.android.gms.cast.framework.CastSession
1721
import com.google.android.gms.cast.framework.SessionManagerListener
22+
import kotlinx.coroutines.launch
1823

1924
class MainActivity : FragmentActivity() {
2025
private val viewModel: MainViewModel by viewModels()
@@ -26,34 +31,12 @@ class MainActivity : FragmentActivity() {
2631

2732
override fun onCreate(savedInstanceState: Bundle?) {
2833
super.onCreate(savedInstanceState)
29-
try {
30-
val castContext = CastContext.getSharedInstance(this)
31-
val listener = object : SessionManagerListener<CastSession> {
32-
override fun onSessionStarted(session: CastSession, sessionId: String) {
33-
viewModel.setCastingConnected(true, session.castDevice?.friendlyName)
34-
viewModel.requestCastStatus()
34+
lifecycleScope.launch {
35+
repeatOnLifecycle(Lifecycle.State.STARTED) {
36+
viewModel.state.collect { state ->
37+
syncCastSessionListener(state.appMode == AppMode.Server)
3538
}
36-
37-
override fun onSessionResumed(session: CastSession, wasSuspended: Boolean) {
38-
viewModel.setCastingConnected(true, session.castDevice?.friendlyName)
39-
viewModel.requestCastStatus()
40-
}
41-
42-
override fun onSessionEnded(session: CastSession, error: Int) {
43-
viewModel.setCastingConnected(false)
44-
}
45-
46-
override fun onSessionStarting(session: CastSession) = Unit
47-
override fun onSessionStartFailed(session: CastSession, error: Int) = Unit
48-
override fun onSessionEnding(session: CastSession) = Unit
49-
override fun onSessionResuming(session: CastSession, sessionId: String) = Unit
50-
override fun onSessionResumeFailed(session: CastSession, error: Int) = Unit
51-
override fun onSessionSuspended(session: CastSession, reason: Int) = Unit
5239
}
53-
castContext.sessionManager.addSessionManagerListener(listener, CastSession::class.java)
54-
sessionListener = listener
55-
} catch (_: Exception) {
56-
// Ignore cast init failures; app still works without it.
5740
}
5841
viewModel.handleDeepLink(intent?.data)
5942
if (intent.getBooleanExtra(EXTRA_OPEN_LISTEN_TAB, false)) {
@@ -97,15 +80,59 @@ class MainActivity : FragmentActivity() {
9780
}
9881

9982
override fun onDestroy() {
100-
sessionListener?.let { listener ->
101-
runCatching {
102-
CastContext.getSharedInstance(this)
103-
.sessionManager
104-
.removeSessionManagerListener(listener, CastSession::class.java)
83+
syncCastSessionListener(enable = false)
84+
super.onDestroy()
85+
}
86+
87+
private fun syncCastSessionListener(enable: Boolean) {
88+
if (!enable) {
89+
sessionListener?.let { listener ->
90+
runCatching {
91+
CastContext.getSharedInstance(this)
92+
.sessionManager
93+
.removeSessionManagerListener(listener, CastSession::class.java)
94+
}
10595
}
96+
sessionListener = null
97+
viewModel.setCastingConnected(false)
98+
return
99+
}
100+
101+
if (sessionListener != null) {
102+
return
103+
}
104+
105+
val castContext = runCatching { CastContext.getSharedInstance(this) }.getOrNull()
106+
?: return
107+
val listener = object : SessionManagerListener<CastSession> {
108+
override fun onSessionStarted(session: CastSession, sessionId: String) {
109+
viewModel.setCastingConnected(true, session.castDevice?.friendlyName)
110+
viewModel.requestCastStatus()
111+
}
112+
113+
override fun onSessionResumed(session: CastSession, wasSuspended: Boolean) {
114+
viewModel.setCastingConnected(true, session.castDevice?.friendlyName)
115+
viewModel.requestCastStatus()
116+
}
117+
118+
override fun onSessionEnded(session: CastSession, error: Int) {
119+
viewModel.setCastingConnected(false)
120+
}
121+
122+
override fun onSessionStarting(session: CastSession) = Unit
123+
override fun onSessionStartFailed(session: CastSession, error: Int) = Unit
124+
override fun onSessionEnding(session: CastSession) = Unit
125+
override fun onSessionResuming(session: CastSession, sessionId: String) = Unit
126+
override fun onSessionResumeFailed(session: CastSession, error: Int) = Unit
127+
override fun onSessionSuspended(session: CastSession, reason: Int) = Unit
128+
}
129+
castContext.sessionManager.addSessionManagerListener(listener, CastSession::class.java)
130+
sessionListener = listener
131+
132+
castContext.sessionManager.currentCastSession?.let { session ->
133+
viewModel.setCastingConnected(true, session.castDevice?.friendlyName)
134+
viewModel.requestCastStatus()
106135
}
107-
sessionListener = null
108-
super.onDestroy()
109136
}
110137

111138
companion object {

android/app/src/main/java/com/foreverjukebox/app/cast/CastAppIdResolver.kt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,15 @@ object CastAppIdResolver {
1414
return map[normalized]
1515
}
1616

17+
// CastContext is initialized once per app process via OptionsProvider and is not re-created
18+
// when the API base URL changes. We fall back to any configured receiver app ID here so
19+
// early startup (before preferences load) still uses a custom receiver. If you need different
20+
// receiver app IDs per base URL, the app must be restarted after changing base URL.
21+
fun resolveAny(context: Context): String? {
22+
val map = cachedMap ?: loadMap(context).also { cachedMap = it }
23+
return map.values.firstOrNull()
24+
}
25+
1726
fun normalize(baseUrl: String?): String? {
1827
val trimmed = baseUrl?.trim()?.trimEnd('/') ?: return null
1928
if (trimmed.isBlank()) return null

android/app/src/main/java/com/foreverjukebox/app/cast/ForeverJukeboxCastOptionsProvider.kt

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
package com.foreverjukebox.app.cast
22

33
import android.content.Context
4-
import com.google.android.gms.cast.CastMediaControlIntent
54
import com.google.android.gms.cast.framework.CastOptions
65
import com.google.android.gms.cast.framework.OptionsProvider
76
import com.google.android.gms.cast.framework.SessionProvider
@@ -13,7 +12,10 @@ class ForeverJukeboxCastOptionsProvider : OptionsProvider {
1312
override fun getCastOptions(context: Context): CastOptions {
1413
val baseUrl = runBlocking { AppPreferences(context).baseUrl.first() }
1514
val appId = CastAppIdResolver.resolve(context, baseUrl)
16-
?: CastMediaControlIntent.DEFAULT_MEDIA_RECEIVER_APPLICATION_ID
15+
?: CastAppIdResolver.resolveAny(context)
16+
?: throw IllegalStateException(
17+
"No Cast receiver app ID configured in cast_app_ids.json"
18+
)
1719
return CastOptions.Builder()
1820
.setReceiverApplicationId(appId)
1921
.setResumeSavedSession(true)

android/app/src/main/java/com/foreverjukebox/app/ui/InputPanel.kt

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
package com.foreverjukebox.app.ui
22

3+
import android.app.ActivityManager
4+
import android.content.Context
35
import android.net.Uri
46
import android.provider.OpenableColumns
57
import androidx.activity.compose.rememberLauncherForActivityResult
@@ -14,6 +16,7 @@ import androidx.compose.material3.Button
1416
import androidx.compose.material3.MaterialTheme
1517
import androidx.compose.material3.Text
1618
import androidx.compose.runtime.Composable
19+
import androidx.compose.runtime.remember
1720
import androidx.compose.ui.Modifier
1821
import androidx.compose.ui.platform.LocalContext
1922
import androidx.compose.ui.unit.dp
@@ -24,6 +27,8 @@ fun InputPanel(
2427
onOpenFile: (Uri, String?) -> Unit
2528
) {
2629
val context = LocalContext.current
30+
val totalRamBytes = remember(context) { resolveTotalRamBytes(context) }
31+
val showLowRamWarning = totalRamBytes != null && totalRamBytes < LOW_RAM_WARNING_THRESHOLD_BYTES
2732
val filePicker = rememberLauncherForActivityResult(
2833
contract = ActivityResultContracts.OpenDocument()
2934
) { uri ->
@@ -58,6 +63,20 @@ fun InputPanel(
5863
verticalArrangement = Arrangement.spacedBy(12.dp)
5964
) {
6065
Text("Input", style = MaterialTheme.typography.labelLarge)
66+
if (showLowRamWarning) {
67+
Card(
68+
colors = CardDefaults.cardColors(
69+
containerColor = MaterialTheme.colorScheme.errorContainer,
70+
contentColor = MaterialTheme.colorScheme.onErrorContainer
71+
)
72+
) {
73+
Text(
74+
text = "Warning: Local analysis may fail on long tracks; 8GB+ RAM is recommended.",
75+
modifier = Modifier.padding(10.dp),
76+
style = MaterialTheme.typography.bodySmall
77+
)
78+
}
79+
}
6180
Text(
6281
"Local mode runs analysis fully on-device and caches the result for faster future playback."
6382
)
@@ -84,3 +103,13 @@ fun InputPanel(
84103
}
85104
}
86105
}
106+
107+
private const val LOW_RAM_WARNING_THRESHOLD_BYTES = 8L * 1024L * 1024L * 1024L
108+
109+
private fun resolveTotalRamBytes(context: Context): Long? {
110+
val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as? ActivityManager
111+
?: return null
112+
val memoryInfo = ActivityManager.MemoryInfo()
113+
activityManager.getMemoryInfo(memoryInfo)
114+
return memoryInfo.totalMem.takeIf { it > 0L }
115+
}

0 commit comments

Comments
 (0)