Skip to content
Merged

Fixes #212

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions android/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ plugins {
id("com.android.application")
id("kotlin-android")
id("androidx.navigation.safeargs.kotlin")
id("com.mikepenz.aboutlibraries.plugin")
id("com.mikepenz.aboutlibraries.plugin.android")
id("kotlin-parcelize")
id("dagger.hilt.android.plugin")
id("com.google.firebase.crashlytics")
Expand Down Expand Up @@ -239,7 +239,7 @@ android {
}

// About Libraries
implementation(libs.mikepenz.aboutlibrariesCore)
implementation(libs.mikepenz.aboutlibraries)

// Billing
implementation(libs.billingclient.billingKtx)
Expand Down
17 changes: 17 additions & 0 deletions android/app/src/main/assets/changelog.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,21 @@
[
{
"versionName": "1.0.9",
"releaseDate": "15/01/2026",
"features": [

],
"fixes": [
"Fixed Chromecast reliability issues",
"Fixed a long-standing issue where the app could crash when playing from the background",
"Various stability improvements"
],
"improvements": [
],
"notes": [
"What's up? I want to make Shuttle great again. I just need more hours in the day, and more days in the week!"
]
},
{
"versionName": "1.0.8",
"releaseDate": "10/01/2026",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ package com.simplecityapps.shuttle.appinitializers

import android.annotation.SuppressLint
import android.app.Application
import android.app.ForegroundServiceStartNotAllowedException
import android.content.Context
import android.content.Intent
import android.os.Build
import androidx.core.content.ContextCompat
import com.simplecityapps.mediaprovider.repository.songs.SongRepository
import com.simplecityapps.playback.NoiseManager
Expand Down Expand Up @@ -158,7 +160,15 @@ constructor(
override fun onPlaybackStateChanged(playbackState: PlaybackState) {
when (playbackState) {
is PlaybackState.Playing -> {
ContextCompat.startForegroundService(context, Intent(context, PlaybackService::class.java))
try {
ContextCompat.startForegroundService(context, Intent(context, PlaybackService::class.java))
} catch (e: IllegalStateException) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && e is ForegroundServiceStartNotAllowedException) {
Timber.w(e, "Cannot start foreground service from background - likely audio focus regained while app in background")
} else {
throw e
}
}
}
is PlaybackState.Paused -> {
playbackPreferenceManager.playbackPosition = playbackManager.getProgress()
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.simplecityapps.shuttle.ui

import android.Manifest
import android.app.ForegroundServiceStartNotAllowedException
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
Expand All @@ -22,6 +23,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.tasks.await
import kotlinx.coroutines.withTimeout
import timber.log.Timber

@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
Expand All @@ -41,7 +43,7 @@ class MainActivity : AppCompatActivity() {
@AppCoroutineScope
lateinit var scope: CoroutineScope

lateinit var snowfallView: SnowfallView
var snowfallView: SnowfallView? = null

// Lifecycle

Expand Down Expand Up @@ -76,7 +78,7 @@ class MainActivity : AppCompatActivity() {
withTimeout(5000) {
remoteConfig.fetchAndActivate().await()
}
snowfallView.setForecast(remoteConfig.getDouble("snow_forecast"))
snowfallView?.setForecast(remoteConfig.getDouble("snow_forecast"))
}
}

Expand Down Expand Up @@ -104,15 +106,23 @@ class MainActivity : AppCompatActivity() {

private fun handleSearchQuery(intent: Intent?) {
if (intent?.action == MediaStore.INTENT_ACTION_MEDIA_PLAY_FROM_SEARCH) {
ContextCompat.startForegroundService(
this,
Intent(this, PlaybackService::class.java).apply {
action = PlaybackService.ACTION_SEARCH
intent.extras?.let { extras ->
putExtras(extras)
try {
ContextCompat.startForegroundService(
this,
Intent(this, PlaybackService::class.java).apply {
action = PlaybackService.ACTION_SEARCH
intent.extras?.let { extras ->
putExtras(extras)
}
}
)
} catch (e: IllegalStateException) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && e is ForegroundServiceStartNotAllowedException) {
Timber.w(e, "Cannot start foreground service from search query - app may be in restricted state")
} else {
throw e
}
)
}
}
}
}
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
package com.simplecityapps.shuttle.ui

import android.app.ForegroundServiceStartNotAllowedException
import android.content.Intent
import android.os.Build
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import com.simplecityapps.playback.PlaybackService
import dagger.hilt.android.AndroidEntryPoint
import timber.log.Timber

@AndroidEntryPoint
class ShortcutHandlerActivity : AppCompatActivity() {
Expand All @@ -19,10 +21,18 @@ class ShortcutHandlerActivity : AppCompatActivity() {
action = PlaybackService.ACTION_TOGGLE_PLAYBACK
}

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
startForegroundService(serviceIntent)
} else {
startService(serviceIntent)
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
startForegroundService(serviceIntent)
} else {
startService(serviceIntent)
}
} catch (e: IllegalStateException) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && e is ForegroundServiceStartNotAllowedException) {
Timber.w(e, "Cannot start foreground service from shortcut - app may be in restricted state")
} else {
throw e
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,9 @@ class TagEditorAlertDialog :
}

fun show(manager: FragmentManager) {
super.show(manager, TAG)
if (!manager.isStateSaved) {
super.show(manager, TAG)
}
}

// Private
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import com.simplecityapps.shuttle.ui.screens.playlistmenu.PlaylistMenuView
import com.squareup.phrase.Phrase
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import timber.log.Timber

@AndroidEntryPoint
class AlbumArtistListFragment :
Expand Down Expand Up @@ -336,13 +337,18 @@ class AlbumArtistListFragment :
viewHolder: AlbumArtistBinder.ViewHolder
) {
if (!contextualToolbarHelper.handleClick(albumArtist)) {
if (findNavController().currentDestination?.id != R.id.albumArtistDetailFragment) {
findNavController().navigate(
R.id.action_libraryFragment_to_albumArtistDetailFragment,
AlbumArtistDetailFragmentArgs(albumArtist).toBundle(),
null,
FragmentNavigatorExtras(viewHolder.imageView to viewHolder.imageView.transitionName)
)
// Verify we're on libraryFragment (where the action is defined) before navigating
if (findNavController().currentDestination?.id == R.id.libraryFragment) {
try {
findNavController().navigate(
R.id.action_libraryFragment_to_albumArtistDetailFragment,
AlbumArtistDetailFragmentArgs(albumArtist).toBundle(),
null,
FragmentNavigatorExtras(viewHolder.imageView to viewHolder.imageView.transitionName)
)
} catch (e: IllegalArgumentException) {
Timber.e(e, "Failed to navigate to album artist detail")
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,8 @@ class MainFragment :
if (task.isSuccessful) {
// We got the ReviewInfo object
val reviewInfo = task.result
reviewManager.launchReviewFlow(requireActivity(), reviewInfo)
// Use nullable activity since fragment may be detached when async callback fires
activity?.let { reviewManager.launchReviewFlow(it, reviewInfo) }
} else {
// There was some problem, log or handle the error code.
Timber.e(task.exception ?: Exception("Unknown"), "Failed to launch review flow")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,10 @@ class OnboardingParentFragment :
generalPreferenceManager.hasOnboarded = true

if (args.isOnboarding) {
findNavController().navigate(R.id.action_onboardingFragment_to_mainFragment)
// Guard against double-tap or race conditions where navigation already occurred
if (findNavController().currentDestination?.id == R.id.onboardingFragment) {
findNavController().navigate(R.id.action_onboardingFragment_to_mainFragment)
}
} else {
findNavController().popBackStack()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import com.google.android.gms.cast.framework.CastButtonFactory
import com.simplecityapps.adapter.RecyclerAdapter
import com.simplecityapps.adapter.RecyclerListener
import com.simplecityapps.playback.PlaybackState
import com.simplecityapps.playback.chromecast.CastSessionManager
import com.simplecityapps.playback.queue.QueueItem
import com.simplecityapps.playback.queue.QueueManager
import com.simplecityapps.shuttle.R
Expand Down Expand Up @@ -64,6 +65,9 @@ class PlaybackFragment :
@Inject
lateinit var queueManager: QueueManager

@Inject
lateinit var castSessionManager: CastSessionManager

private var recyclerView: RecyclerView by autoCleared()

private var adapter: RecyclerAdapter by autoCleared()
Expand Down Expand Up @@ -224,7 +228,11 @@ class PlaybackFragment :
presenter.setFavorite(favoriteButton.isChecked)
}

CastButtonFactory.setUpMediaRouteButton(requireContext(), toolbar.menu, R.id.media_route_menu_item)
if (castSessionManager.isAvailable) {
CastButtonFactory.setUpMediaRouteButton(requireContext(), toolbar.menu, R.id.media_route_menu_item)
} else {
toolbar.menu.findItem(R.id.media_route_menu_item)?.isVisible = false
}

savedInstanceState?.getParcelable<Parcelable>(QueueFragment.ARG_RECYCLER_STATE)?.let { recyclerViewState = it }

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import kotlinx.datetime.LocalDate
import kotlinx.parcelize.Parceler

object LocalDateParceler : Parceler<LocalDate?> {
override fun create(parcel: Parcel) = LocalDate.parse(parcel.readString()!!)
override fun create(parcel: Parcel): LocalDate? = parcel.readString()?.let { LocalDate.parse(it) }

override fun LocalDate?.write(
parcel: Parcel,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,10 @@ constructor(
notificationManager.cancel(NOTIFICATION_ID)
}

fun notify(notification: Notification) {
notificationManager.notify(NOTIFICATION_ID, notification)
}

private val playbackAction: NotificationCompat.Action
get() {
val intent =
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package com.simplecityapps.playback

import android.app.ForegroundServiceStartNotAllowedException
import android.app.Notification
import android.app.SearchManager
import android.app.Service
import android.content.Intent
Expand Down Expand Up @@ -91,7 +93,7 @@ class PlaybackService :

Timber.v("onStartCommand() action: ${intent?.action}")

if (intent == null && (playbackManager.playbackState() != PlaybackState.Loading || playbackManager.playbackState() != PlaybackState.Playing)) {
if (intent == null && (playbackManager.playbackState() != PlaybackState.Loading && playbackManager.playbackState() != PlaybackState.Playing)) {
stopForeground(true)
return START_NOT_STICKY
}
Expand All @@ -104,8 +106,15 @@ class PlaybackService :
intent?.let {
when (intent.action) {
ACTION_NOTIFICATION_DISMISS -> {
// The user has swiped away the notification. This is only possible when the service is no longer running in the foreground
// The user has swiped away the notification. This is only possible when the service is no longer running in the foreground.
// We still need to call startForeground() in case this was started via startForegroundService().
Timber.v("Stopping due to notification dismiss")
val notification = if (queueManager.getQueue().isEmpty()) {
notificationManager.displayQueueEmptyNotification()
} else {
notificationManager.displayPlaybackNotification()
}
startForegroundSafely(notification)
stopSelf()
return START_NOT_STICKY
}
Expand All @@ -123,16 +132,16 @@ class PlaybackService :
This also gives S2 time to respond to pending commands. For example, if the command is 'loadFromSearch', we don't want to stop the service
and allow the process to be killed while that we're in the middle of executing that command.
*/
startForeground(PlaybackNotificationManager.NOTIFICATION_ID, notificationManager.displayQueueEmptyNotification())
startForegroundSafely(notificationManager.displayQueueEmptyNotification())
postDelayedShutdown(10000)
} else {
Timber.v("startForeground() called. Showing notification: Playback")
startForeground(PlaybackNotificationManager.NOTIFICATION_ID, notificationManager.displayPlaybackNotification())
startForegroundSafely(notificationManager.displayPlaybackNotification())
}
processCommand(intent)
} else {
Timber.v("startForeground() called. Showing notification: Loading")
startForeground(PlaybackNotificationManager.NOTIFICATION_ID, notificationManager.displayLoadingNotification())
startForegroundSafely(notificationManager.displayLoadingNotification())
pendingStartCommands.add(intent)
}
}
Expand Down Expand Up @@ -178,6 +187,21 @@ class PlaybackService :

// Private

private fun startForegroundSafely(notification: Notification) {
try {
startForeground(PlaybackNotificationManager.NOTIFICATION_ID, notification)
} catch (e: IllegalStateException) {
// ForegroundServiceStartNotAllowedException (API 31+) extends IllegalStateException
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && e is ForegroundServiceStartNotAllowedException) {
Timber.w(e, "Unable to start foreground service - likely started from background context (e.g., media button broadcast)")
// Still display the notification even if we can't start foreground
notificationManager.notify(notification)
} else {
throw e
}
}
}

private fun processCommand(intent: Intent) {
Timber.v("processCommand()")
MediaButtonReceiver.handleIntent(mediaSessionManager.mediaSession, intent)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,16 +36,16 @@ class PlaybackWatcher : PlaybackWatcherCallback {
duration: Int,
fromUser: Boolean
) {
callbacks.forEach { callback -> callback.onProgressChanged(position, duration, fromUser) }
callbacks.toList().forEach { callback -> callback.onProgressChanged(position, duration, fromUser) }
}

override fun onPlaybackStateChanged(playbackState: PlaybackState) {
Timber.v("onPlaybackStateChanged(playbackState: $playbackState)")
callbacks.forEach { callback -> callback.onPlaybackStateChanged(playbackState) }
callbacks.toList().forEach { callback -> callback.onPlaybackStateChanged(playbackState) }
}

override fun onTrackEnded(song: Song) {
Timber.v("onTrackEnded(song: ${song.name})")
callbacks.forEach { callback -> callback.onTrackEnded(song) }
callbacks.toList().forEach { callback -> callback.onTrackEnded(song) }
}
}
Loading