Skip to content

Commit d18747e

Browse files
committed
Remove notification tap fix hack
1 parent 0918c78 commit d18747e

File tree

1 file changed

+10
-303
lines changed

1 file changed

+10
-303
lines changed

src/Bible.Alarm/Platforms/Android/Services/Media/AndroidPlayerNotificationService.cs

Lines changed: 10 additions & 303 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)