@@ -51,7 +51,6 @@ public class AndroidPlayerNotificationService : IAndroidPlayerNotificationServic
5151 private readonly IState < PlaybackState > _playbackState ;
5252 private ExoPlayerListener ? _exoPlayerListener ;
5353 private IExoPlayer ? _currentPlayer ;
54- private CancellationTokenSource ? _notificationOverrideCancellationTokenSource ;
5554
5655 public AndroidPlayerNotificationService ( ILogger logger , IState < PlaybackState > playbackState )
5756 {
@@ -176,27 +175,13 @@ public void SetSourceWithDummyQueue(MediaElement mediaElement, string uri, bool
176175 // Set up listener to intercept Next/Previous button presses
177176 SetupExoPlayerListener ( player ) ;
178177
179- // Set notification body click to open app
180- SetNotificationClickToOpenApp ( mediaElement ) ;
181-
182- // Start/restart continuous notification override timer (800ms interval) if needed
183- // This is a workaround for MediaElement 7.0.0 bug where it recreates the notification
184- // and overwrites our custom ContentIntent. The timer continuously reapplies our override.
185- // Only restart if timer is not running or has been cancelled
186- var needsRestart = _notificationOverrideCancellationTokenSource == null
187- || _notificationOverrideCancellationTokenSource . IsCancellationRequested ;
188-
189- if ( needsRestart )
190- {
191- StartNotificationIntentOverrideTimer ( ) ;
192- }
193-
194178 // Force MediaSession to refresh actions
195179 TryUpdateMediaSessionActions ( mediaElement ) ;
196180
197181 // Log confirmation that Next/Previous buttons are enabled
198182 // This confirms the implementation is ready for Pixel 7a and all Android devices
199- _logger . Information ( "NEXT/PREV BUTTONS ENABLED — Pixel 7a ready. Queue configured with {ItemCount} items. Continuous notification override timer started (800ms interval - workaround for MediaElement 7.0.0 bug)." , itemCount ) ;
183+ // MediaSession.SetSessionActivity() is configured in MediaManager to handle notification body taps on Android 14+
184+ _logger . Information ( "NEXT/PREV BUTTONS ENABLED — Pixel 7a ready. Queue configured with {ItemCount} items. MediaSession.SetSessionActivity() configured for notification body taps." , itemCount ) ;
200185 }
201186 catch ( Exception ex )
202187 {
@@ -205,298 +190,26 @@ public void SetSourceWithDummyQueue(MediaElement mediaElement, string uri, bool
205190 }
206191
207192 /// <summary>
208- /// Sets up notification body tap handling using SessionActivity.
209- /// This works on Android 8-13 and many Android 14+ devices (Samsung, Xiaomi, etc.).
210- /// For Pixel 7a and other stock Android 14+ devices, we also continuously override the notification's ContentIntent.
193+ /// Brings the app to the foreground when notification is tapped.
211194 /// </summary>
212- private void SetNotificationClickToOpenApp ( MediaElement mediaElement )
195+ private void BringAppToForeground ( )
213196 {
214- var session = GetMediaSession ( mediaElement ) ;
215- if ( session == null ) return ;
216-
217- var sessionType = session . GetType ( ) ;
218-
219- // Set SessionActivity - this works on Android 8-13 and many Android 14+ devices
220197 try
221198 {
222199 var context = Microsoft . Maui . ApplicationModel . Platform . AppContext ;
223200 var intent = new Intent ( context , typeof ( MainActivity ) ) ;
224201 intent . SetFlags ( ActivityFlags . NewTask | ActivityFlags . ClearTop | ActivityFlags . SingleTop ) ;
225- intent . SetAction ( "Bible.Alarm.NOTIFICATION_CLICK" ) ;
226-
227- if ( _playbackState . Value . CurrentScheduleId . HasValue )
228- intent . PutExtra ( "schedule_id" , _playbackState . Value . CurrentScheduleId . Value ) ;
229-
230- var pendingIntent = PendingIntent . GetActivity (
231- context ,
232- 999999 ,
233- intent ,
234- PendingIntentFlags . UpdateCurrent | PendingIntentFlags . Immutable ) ;
235-
236- sessionType . GetProperty ( "SessionActivity" , BindingFlags . Public | BindingFlags . Instance ) ?
237- . SetValue ( session , pendingIntent ) ;
238-
239- _logger . Debug ( "Set SessionActivity for notification body taps" ) ;
240- }
241- catch ( Exception ex )
242- {
243- _logger . Debug ( ex , "Failed to set SessionActivity" ) ;
244- }
245- }
246-
247- /// <summary>
248- /// Overrides the media notification to set MediaSession token to null in MediaStyle.
249- /// This forces Android 14+ to use ContentIntent for body taps instead of routing to OnPlay() callback.
250- /// Key insight: MediaStyle with null MediaSession token → body taps use ContentIntent (works on all Android versions).
251- /// MediaStyle with MediaSession token → body taps go to OnPlay() (Android 14+ only).
252- /// Note: mediaElement parameter is unused but kept for backward compatibility with existing call sites.
253- /// </summary>
254- private void OverrideNotificationIntent ( MediaElement ? mediaElement = null )
255- {
256- try
257- {
258- var context = Microsoft . Maui . ApplicationModel . Platform . AppContext ;
259- var notificationManager = context . GetSystemService ( Context . NotificationService ) as NotificationManager ;
260- if ( notificationManager == null ) return ;
261-
262- const int MEDIA_NOTIFICATION_ID = 1 ; // MediaElement 7.0.0 uses notification ID 1
263-
264- // Check if notification exists
265- var activeNotifications = notificationManager . GetActiveNotifications ( ) ;
266- var existingNotification = activeNotifications ? . FirstOrDefault ( n => n . Id == MEDIA_NOTIFICATION_ID ) ;
267- if ( existingNotification ? . Notification == null )
268- {
269- // Notification not posted yet, will be called again when it's available
270- return ;
271- }
272-
273- // Create our custom intent to open the app
274- var intent = new Intent ( context , typeof ( MainActivity ) ) ;
275- intent . SetFlags ( ActivityFlags . NewTask | ActivityFlags . ClearTop | ActivityFlags . SingleTop ) ;
276- intent . SetAction ( "Bible.Alarm.NOTIFICATION_CLICK" ) ;
277-
278- if ( _playbackState . Value . CurrentScheduleId . HasValue )
279- intent . PutExtra ( "schedule_id" , _playbackState . Value . CurrentScheduleId . Value ) ;
280-
281- var pendingIntent = PendingIntent . GetActivity (
282- context ,
283- 999999 ,
284- intent ,
285- PendingIntentFlags . UpdateCurrent | PendingIntentFlags . Immutable ) ;
286-
287- // Get channel ID from existing notification
288- var channelId = existingNotification . Notification . ChannelId ?? "1" ;
289-
290- // Create MediaStyle with null MediaSession token - this is the key!
291- // Setting token to null forces Android 14+ to use ContentIntent for body taps
292- var mediaStyle = new MediaStyle ( )
293- . SetMediaSession ( null ) ; // ← KEY: null token forces ContentIntent for body taps
294-
295- // Preserve existing actions (play/pause, prev, next)
296- var actionIndices = new List < int > ( ) ;
297- if ( existingNotification . Notification . Actions != null )
298- {
299- for ( int i = 0 ; i < existingNotification . Notification . Actions . Count ; i ++ )
300- {
301- actionIndices . Add ( i ) ;
302- }
303- if ( actionIndices . Count > 0 )
304- {
305- mediaStyle . SetShowActionsInCompactView ( actionIndices . ToArray ( ) ) ;
306- }
307- }
308-
309- // Rebuild notification with our custom ContentIntent and null MediaSession token
310- var builder = new NotificationCompat . Builder ( context , channelId ) ;
311-
312- // Copy essential properties from existing notification using NotificationCompat extras
313- var extras = NotificationCompat . GetExtras ( existingNotification . Notification ) ;
314- if ( extras != null )
315- {
316- // Extract title and text from extras
317- var title = extras . GetString ( NotificationCompat . ExtraTitle ) ;
318- var text = extras . GetString ( NotificationCompat . ExtraText ) ;
319-
320- if ( ! string . IsNullOrEmpty ( title ) )
321- builder . SetContentTitle ( title ) ;
322- if ( ! string . IsNullOrEmpty ( text ) )
323- builder . SetContentText ( text ) ;
324- }
325-
326- // Get small icon from existing notification using reflection (required for notification to be valid)
327- bool smallIconSet = false ;
328- try
329- {
330- var smallIconField = existingNotification . Notification . GetType ( ) . GetField ( "mSmallIcon" ,
331- System . Reflection . BindingFlags . NonPublic | System . Reflection . BindingFlags . Instance ) ;
332- if ( smallIconField != null )
333- {
334- var smallIcon = smallIconField . GetValue ( existingNotification . Notification ) ;
335- if ( smallIcon != null )
336- {
337- // Try to get the icon resource ID
338- var iconType = smallIcon . GetType ( ) ;
339- var iconResIdProperty = iconType . GetProperty ( "mResourceId" ,
340- System . Reflection . BindingFlags . NonPublic | System . Reflection . BindingFlags . Instance ) ;
341- if ( iconResIdProperty != null )
342- {
343- var iconResId = iconResIdProperty . GetValue ( smallIcon ) ;
344- if ( iconResId is int resId && resId != 0 )
345- {
346- builder . SetSmallIcon ( resId ) ;
347- smallIconSet = true ;
348- }
349- }
350- }
351- }
352- }
353- catch ( Exception ex )
354- {
355- _logger . Debug ( ex , "Failed to extract small icon from existing notification" ) ;
356- }
357-
358- // Ensure we have a small icon (required for notification to be valid)
359- // Use default media play icon if we couldn't extract it
360- if ( ! smallIconSet )
361- {
362- builder . SetSmallIcon ( global ::Android . Resource . Drawable . IcMediaPlay ) ;
363- }
364-
365- // Set our custom ContentIntent and MediaStyle with null token
366- builder . SetContentIntent ( pendingIntent )
367- . SetStyle ( mediaStyle )
368- . SetOngoing ( existingNotification . Notification . Flags . HasFlag ( NotificationFlags . OngoingEvent ) )
369- . SetVisibility ( NotificationCompat . VisibilityPublic ) ;
370-
371- // Preserve existing actions - convert from Android.App.Notification.Action to NotificationCompat.Action
372- if ( existingNotification . Notification . Actions != null )
373- {
374- foreach ( var action in existingNotification . Notification . Actions )
375- {
376- try
377- {
378- // Convert Android.App.Notification.Action to NotificationCompat.Action
379- var icon = action . Icon != null ? AndroidX . Core . Graphics . Drawable . IconCompat . CreateFromIcon ( action . Icon ) : null ;
380- var title = action . Title ? . ToString ( ) ?? "" ;
381- var actionIntent = action . ActionIntent ;
382-
383- if ( icon != null && actionIntent != null )
384- {
385- var compatAction = new NotificationCompat . Action ( icon , title , actionIntent ) ;
386- builder . AddAction ( compatAction ) ;
387- }
388- }
389- catch ( Exception ex )
390- {
391- _logger . Debug ( ex , "Failed to copy notification action" ) ;
392- }
393- }
394- }
395-
396- // Update the notification
397- notificationManager . Notify ( MEDIA_NOTIFICATION_ID , builder . Build ( ) ) ;
398-
399- // Debug level since this runs every 800ms via timer
400- _logger . Debug ( "Notification override applied — body tap uses ContentIntent (null MediaSession token bypasses Android 14+ hijack)" ) ;
401- }
402- catch ( Exception ex )
403- {
404- _logger . Debug ( ex , "Failed to override notification intent" ) ;
405- }
406- }
407-
408- /// <summary>
409- /// Starts a continuous timer that overrides the notification ContentIntent every 800ms.
410- /// This is a workaround for MediaElement 7.0.0 bug where it continuously recreates the notification
411- /// and overwrites our custom ContentIntent. The timer ensures our override is always applied.
412- /// This is the only known working solution for MediaElement 7.0.0 on all Android versions.
413- /// The timer automatically stops when playback stops (checks playback state each iteration).
414- /// </summary>
415- private void StartNotificationIntentOverrideTimer ( )
416- {
417- // Stop any existing timer first (prevents duplicate timers when new item is queued)
418- StopNotificationIntentOverrideTimer ( ) ;
419-
420- _notificationOverrideCancellationTokenSource = new CancellationTokenSource ( ) ;
421- var cancellationToken = _notificationOverrideCancellationTokenSource . Token ;
422-
423- // Start background task that continuously overrides the notification
424- Task . Run ( async ( ) =>
425- {
426- while ( ! cancellationToken . IsCancellationRequested )
427- {
428- try
429- {
430- await Task . Delay ( 800 , cancellationToken ) ; // 800ms interval
431-
432- if ( cancellationToken . IsCancellationRequested )
433- break ;
434-
435- // Check if playback is still active - stop timer if playback has stopped
436- if ( ! _playbackState . Value . IsPreparingOrPlaying )
437- {
438- _logger . Debug ( "Playback stopped - stopping notification override timer" ) ;
439- StopNotificationIntentOverrideTimer ( ) ;
440- break ;
441- }
442-
443- // Override notification if playback is active (no MediaElement reference needed)
444- if ( _playbackState . Value . IsPreparingOrPlaying )
445- {
446- MainThread . BeginInvokeOnMainThread ( ( ) =>
447- {
448- try
449- {
450- // Double-check playback state on main thread
451- if ( _playbackState . Value . IsPreparingOrPlaying )
452- {
453- OverrideNotificationIntent ( ) ; // No MediaElement needed - we get notification directly
454- }
455- }
456- catch ( System . OperationCanceledException )
457- {
458- // Expected when cancellation is requested
459- }
460- catch ( Exception ex )
461- {
462- _logger . Warning ( ex , "Failed to override notification intent (timer)" ) ;
463- }
464- } ) ;
465- }
466- }
467- catch ( System . OperationCanceledException )
468- {
469- // Expected when cancellation is requested
470- break ;
471- }
472- catch ( Exception ex )
473- {
474- _logger . Warning ( ex , "Error in notification override timer" ) ;
475- }
476- }
477- } , cancellationToken ) ;
478-
479- _logger . Debug ( "Started continuous notification override timer (800ms interval)" ) ;
480- }
481-
482- /// <summary>
483- /// Stops the continuous notification override timer.
484- /// </summary>
485- private void StopNotificationIntentOverrideTimer ( )
486- {
487- try
488- {
489- _notificationOverrideCancellationTokenSource ? . Cancel ( ) ;
490- _notificationOverrideCancellationTokenSource ? . Dispose ( ) ;
491- _notificationOverrideCancellationTokenSource = null ;
492- _logger . Debug ( "Stopped notification override timer" ) ;
202+ intent . SetAction ( "Bible.Alarm.NOTIFICATION_TAP" ) ;
203+
204+ context . StartActivity ( intent ) ;
205+
206+ _logger . Debug ( "Brought app to foreground via NotificationTapAction" ) ;
493207 }
494208 catch ( Exception ex )
495209 {
496- _logger . Debug ( ex , "Error stopping notification override timer " ) ;
210+ _logger . Warning ( ex , "Failed to bring app to foreground from notification tap " ) ;
497211 }
498212 }
499-
500213 /// <summary>
501214 /// Updates MediaSession to refresh actions after setting dummy queue.
502215 /// In Media3, actions are typically derived from Player state, but we may need to invalidate the session.
@@ -794,9 +507,6 @@ private async Task ReleaseMediaSessionInternalAsync(MediaElement mediaElement)
794507 {
795508 _logger . Information ( "Removing notification by disconnecting handler (handler will be recreated on next Play)" ) ;
796509
797- // Stop the notification override timer
798- StopNotificationIntentOverrideTimer ( ) ;
799-
800510 // 1. Stop playback and reset source (triggers internal cleanup)
801511 await MainThread . InvokeOnMainThreadAsync ( ( ) =>
802512 {
@@ -875,9 +585,6 @@ public void Dispose()
875585 {
876586 try
877587 {
878- // Stop the notification override timer
879- StopNotificationIntentOverrideTimer ( ) ;
880-
881588 // Note: ReleaseMediaSession is now called from AudioPlayer with MediaElement instance
882589 // We don't call it here since we no longer have a MediaElement reference
883590
0 commit comments