diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index baac3e7..54c852d 100755 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,6 +1,15 @@ + + + + + + + + + + @@ -15,13 +25,14 @@ - - - - + + + + - + diff --git a/app/src/main/java/com/hyperling/apps/infinitetimer/Alert.java b/app/src/main/java/com/hyperling/apps/infinitetimer/Alert.java index afbb670..f49574e 100644 --- a/app/src/main/java/com/hyperling/apps/infinitetimer/Alert.java +++ b/app/src/main/java/com/hyperling/apps/infinitetimer/Alert.java @@ -1,125 +1,85 @@ package com.hyperling.apps.infinitetimer; -import android.app.Notification; -import android.app.NotificationChannel; -import android.app.NotificationManager; +import android.app.AlarmManager; import android.app.PendingIntent; -import android.app.Service; +import android.content.Context; import android.content.Intent; -import android.media.MediaPlayer; -import android.media.RingtoneManager; -import android.net.Uri; import android.os.Build; -import android.os.Handler; -import android.os.IBinder; -import android.os.Looper; -import android.support.v4.app.NotificationCompat; +import android.provider.Settings; import android.util.Log; -public class Alert extends Service { +public class Alert { + private static final String TAG = "Alert"; + public static final String ACTION_TIMER = "com.hyperling.apps.infinitetimer.ACTION_TIMER"; + + /** + * Sets a persistent alarm that triggers the TimerReceiver. + * Uses setExactAndAllowWhileIdle to bypass Android Doze mode. + */ + public static void start(Context context, long intervalMs) { + Intent intent = new Intent(context, TimerReceiver.class); + intent.setAction(ACTION_TIMER); - boolean DEBUG = true; - String TAG = "Alert.", tag; - private static final String CHANNEL_ID = "alert_service_channel"; - private static final int NOTIFICATION_ID = 1; - - Uri ringtoneUri; - private MediaPlayer mediaPlayer; - private Handler handler = new Handler(Looper.getMainLooper()); - private long intervalMs; // user-defined frequency - - private final Runnable alertRunnable = new Runnable() { - @Override - public void run() { - playAlert(); - handler.postDelayed(this, intervalMs); - } - }; - - @Override - public int onStartCommand(Intent intent, int flags, int startId) { - tag = TAG + "onStartCommand"; - - if (intent != null) { - intervalMs = intent.getLongExtra("interval_ms", 5000L); + // FLAG_IMMUTABLE required for Android 12+ (API 31+) + int flags = PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_CANCEL_CURRENT; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + flags |= PendingIntent.FLAG_IMMUTABLE; } - createNotificationChannel(); - startForeground(NOTIFICATION_ID, buildNotification()); + PendingIntent pendingIntent = PendingIntent.getBroadcast(context, 0, intent, flags); + AlarmManager alarmManager = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE); - // Start the alert loop (your original Runnable logic) - handler.post(alertRunnable); - - return START_STICKY; - } - - private void playAlert() { - // Your existing audio playback code goes here - if (mediaPlayer == null) { - ringtoneUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION); - if (DEBUG) Log.d(tag, "ringtoneUri=" + ringtoneUri.toString()); - mediaPlayer = MediaPlayer.create(this, ringtoneUri); - if (mediaPlayer == null) { - if (DEBUG) Log.d(tag, "Media Player is null, trying alarm"); - ringtoneUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_ALARM); - mediaPlayer = MediaPlayer.create(this, ringtoneUri); + if (alarmManager != null) { + long triggerTime = System.currentTimeMillis() + intervalMs; + // Exact alarm ensures it triggers even in Doze mode + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + alarmManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerTime, pendingIntent); + } else { + alarmManager.setExact(AlarmManager.RTC_WAKEUP, triggerTime, pendingIntent); } - if (mediaPlayer == null) { - if (DEBUG) Log.d(tag, "Media Player is null, trying ringtone"); - ringtoneUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_RINGTONE); - mediaPlayer = MediaPlayer.create(this, ringtoneUri); - } - if (mediaPlayer == null) { - if (DEBUG) Log.d(tag, "Media Player is null, trying all"); - ringtoneUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_ALL); - mediaPlayer = MediaPlayer.create(this, ringtoneUri); - } - if (mediaPlayer == null) { - if (DEBUG) Log.d(tag, "Media Player is null, trying local sound byte"); - mediaPlayer = MediaPlayer.create(this, R.raw.chime_sound_7143); - } - mediaPlayer.seekTo(0); - mediaPlayer.start(); + Log.d(TAG, "Alarm set for " + intervalMs + "ms at " + triggerTime); + } else { + Log.e(TAG, "Could not get AlarmManager service"); } } - @Override - public void onDestroy() { - super.onDestroy(); - handler.removeCallbacks(alertRunnable); - if (mediaPlayer != null) { - mediaPlayer.release(); - mediaPlayer = null; + /** + * Cancels the timer. If the app is killed, alarms are dropped automatically. + */ + public static void stop(Context context) { + Intent intent = new Intent(context, TimerReceiver.class); + int flags = PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_CANCEL_CURRENT; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + flags |= PendingIntent.FLAG_IMMUTABLE; + } + + PendingIntent pendingIntent = PendingIntent.getBroadcast(context, 0, intent, flags); + AlarmManager alarmManager = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE); + + if (alarmManager != null) { + alarmManager.cancel(pendingIntent); + Log.d(TAG, "Alarm cancelled"); } } - @Override - public IBinder onBind(Intent intent) { - return null; + /** + * Checks/Requests permission for exact alarms on Android 12+ + */ + public static boolean isExactAlarmAllowed(Context context) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + AlarmManager am = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE); + return am != null && am.canScheduleExactAlarms(); + } + return true; // Android 11 and below allow exact alarms by default } - private Notification buildNotification() { - Intent notificationIntent = new Intent(this, MainActivity.class); - PendingIntent pendingIntent = PendingIntent.getActivity( - this, 0, notificationIntent, - PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE - ); - - return new NotificationCompat.Builder(this, CHANNEL_ID) - .setContentTitle("Alert Service Running") - .setContentText("Playing alerts at " + intervalMs + "ms intervals") - .setSmallIcon(R.drawable.ic_notification) - .setOngoing(true) - .setContentIntent(pendingIntent) - .build(); - } - - private void createNotificationChannel() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - NotificationChannel channel = new NotificationChannel( - CHANNEL_ID, "Alert Service", NotificationManager.IMPORTANCE_LOW); - getSystemService(NotificationManager.class) - .createNotificationChannel(channel); + /** + * Helper to request exact alarm permission if missing + */ + public static void requestExactAlarmPermission(android.app.Activity activity) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && !isExactAlarmAllowed(activity)) { + Intent intent = new Intent(Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM); + activity.startActivity(intent); } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/hyperling/apps/infinitetimer/MainActivity.java b/app/src/main/java/com/hyperling/apps/infinitetimer/MainActivity.java index 1ff5518..181545b 100755 --- a/app/src/main/java/com/hyperling/apps/infinitetimer/MainActivity.java +++ b/app/src/main/java/com/hyperling/apps/infinitetimer/MainActivity.java @@ -5,13 +5,14 @@ import android.content.DialogInterface; import android.content.SharedPreferences; import android.graphics.Color; import android.media.AudioManager; -import android.media.MediaPlayer; import android.media.Ringtone; import android.media.RingtoneManager; import android.net.Uri; +import android.os.Build; import android.os.Bundle; import android.os.Handler; import android.os.Message; +import android.provider.Settings; import android.support.v7.app.AlertDialog; import android.support.v7.app.AppCompatActivity; import android.util.Log; @@ -91,48 +92,11 @@ public class MainActivity extends AppCompatActivity { if (DEBUG) Log.d(tag, "Got preferences."); // Sound - ringtoneUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION); - if (DEBUG) Log.d(tag, "ringtoneUri=" + ringtoneUri.toString()); - mediaPlayer = MediaPlayer.create(this, ringtoneUri); - if (mediaPlayer == null) { - if (DEBUG) Log.d(tag, "Media Player is null, trying alarm"); - ringtoneUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_ALARM); - mediaPlayer = MediaPlayer.create(this, ringtoneUri); - } - if (mediaPlayer == null) { - if (DEBUG) Log.d(tag, "Media Player is null, trying ringtone"); - ringtoneUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_RINGTONE); - mediaPlayer = MediaPlayer.create(this, ringtoneUri); - } - if (mediaPlayer == null) { - if (DEBUG) Log.d(tag, "Media Player is null, trying all"); - ringtoneUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_ALL); - mediaPlayer = MediaPlayer.create(this, ringtoneUri); - } - if (mediaPlayer == null) { - if (DEBUG) Log.d(tag, "Media Player is null, trying local sound byte"); - mediaPlayer = MediaPlayer.create(this, R.raw.chime_sound_7143); - } - if (mediaPlayer == null) { - String message = "Could not find a notification, alarm, or ringtone to play."; - message += " App will not be able to function without one of these."; - message += " Please exit and install a system notification sound."; - - new AlertDialog.Builder(MainActivity.this) - .setTitle("Error") - .setMessage(message) - .setPositiveButton("Exit", new DialogInterface.OnClickListener() { - @Override - public void onClick(DialogInterface dialog, int which) { - dialog.dismiss(); - finish(); - } - }) - .setCancelable(false) - .show() - ; - } else { - if (DEBUG) Log.d(tag, "mediaPlayer=" + mediaPlayer.toString()); + // Check exact alarm permission for Android 12+ + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && !Alert.isExactAlarmAllowed(this)) { + if (DEBUG) Log.w(tag, "Exact alarm permission not granted, requesting..."); + Intent intent = new Intent(Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM); + startActivity(intent); } if (DEBUG){ @@ -383,6 +347,20 @@ public class MainActivity extends AppCompatActivity { setLoopInterval(); resetTimeLeft(); setStartTime(); + + // Start the background alert service using Alert manager + long interval = longLoopInterval > 1000 ? longLoopInterval : 60000; // Minimum 1 minute interval for alarms + if (Alert.isExactAlarmAllowed(this)) { + Alert.start(this, interval); + } else { + if (DEBUG) Log.w(tag, "Cannot start alert: Exact alarm permission denied."); + new AlertDialog.Builder(MainActivity.this) + .setTitle("Permission Required") + .setMessage("Please grant 'All files access' or 'Alarm' permissions in system settings for this feature to work.") + .setPositiveButton("OK", (d, w) -> d.dismiss()) + .show(); + } + } else if (buttonText.equals(getString(R.string.btnPause))){ btnStartStop.setText(getString(R.string.btnResume)); @@ -665,6 +643,9 @@ public class MainActivity extends AppCompatActivity { String tag = TAG + "resetAll"; if (DEBUG) Log.d(tag, "Starting"); + // Stop the background alert service when resetting + Alert.stop(this); + if (DEBUG) Log.d(tag, "Resetting screen values..."); setChronometer(0); setEditTexts(""); @@ -745,8 +726,9 @@ public class MainActivity extends AppCompatActivity { String tag = TAG + "onDestroy"; if (DEBUG) Log.d(tag, "Starting"); - resetAll(); - + // Stop the background alert service when destroying activity + Alert.stop(this); + if (DEBUG) Log.d(tag, "Done with local code, calling super.."); super.onDestroy(); diff --git a/app/src/main/java/com/hyperling/apps/infinitetimer/TimerForegroundService.java b/app/src/main/java/com/hyperling/apps/infinitetimer/TimerForegroundService.java new file mode 100644 index 0000000..381c2c8 --- /dev/null +++ b/app/src/main/java/com/hyperling/apps/infinitetimer/TimerForegroundService.java @@ -0,0 +1,94 @@ +package com.hyperling.apps.infinitetimer; + +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.Service; +import android.content.Intent; +import android.media.MediaPlayer; +import android.net.Uri; +import android.os.Build; +import android.os.IBinder; +import android.os.PowerManager; +import android.util.Log; + +public class TimerForegroundService extends Service { + private static final String TAG = "TimerFGService"; + private static final String CHANNEL_ID = "timer_notification_channel"; + private static final int NOTIFICATION_ID = 1001; + + private PowerManager.WakeLock wakeLock; + private MediaPlayer mediaPlayer; + + @Override + public void onCreate() { + super.onCreate(); + createNotificationChannel(); + + // Android 8+ requires foreground service to play sound reliably while backgrounded + startForeground(NOTIFICATION_ID, buildNotification()); + + PowerManager pm = (PowerManager) getSystemService(POWER_SERVICE); + wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "TimerWakeLock"); + // Lock for up to 60 seconds to ensure audio finishes playing even if CPU sleeps + wakeLock.acquire(60 * 1000L); + + playSound(); + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + return START_NOT_STICKY; // Don't restart immediately if killed + } + + private void createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + NotificationChannel channel = new NotificationChannel(CHANNEL_ID, "Timer Notifications", NotificationManager.IMPORTANCE_HIGH); + // Disable default sound to avoid double-sound on some OEMs + channel.setSound(null, null); + channel.enableVibration(false); + NotificationManager manager = getSystemService(NotificationManager.class); + if (manager != null) manager.createNotificationChannel(channel); + } + } + + private android.app.Notification buildNotification() { + return new android.app.NotificationCompat.Builder(this, CHANNEL_ID) + .setContentTitle("Infinite Timer") + .setContentText("Timer is active...") + // Use system alarm icon temporarily. Add your own ic_stat_notification if desired + .setSmallIcon(android.R.drawable.ic_lock_idle_alarm) + .setPriority(android.app.NotificationCompat.PRIORITY_HIGH) + .setOngoing(true) + .build(); + } + + private void playSound() { + if (mediaPlayer != null && mediaPlayer.isPlaying()) return; + + try { + // Plays default notification sound. Replace with R.raw.your_sound if you want custom audio + Uri uri = android.media.RingtoneManager.getDefaultUri(android.media.RingtoneManager.TYPE_NOTIFICATION); + mediaPlayer = MediaPlayer.create(this, uri); + + if (mediaPlayer != null) { + mediaPlayer.setOnCompletionListener(mp -> stopSelf()); // Stop immediately after playing + mediaPlayer.start(); + } else { + stopSelf(); // Fallback if media player fails + } + } catch (Exception e) { + Log.e(TAG, "Failed to play sound", e); + stopSelf(); + } + } + + @Override + public IBinder onBind(Intent intent) { return null; } + + @Override + public void onDestroy() { + super.onDestroy(); + if (wakeLock.isHeld()) wakeLock.release(); + if (mediaPlayer != null) { mediaPlayer.release(); mediaPlayer = null; } + } +} diff --git a/app/src/main/java/com/hyperling/apps/infinitetimer/TimerReceiver.java b/app/src/main/java/com/hyperling/apps/infinitetimer/TimerReceiver.java new file mode 100644 index 0000000..fcc86c9 --- /dev/null +++ b/app/src/main/java/com/hyperling/apps/infinitetimer/TimerReceiver.java @@ -0,0 +1,20 @@ +package com.hyperling.apps.infinitetimer; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.util.Log; + +public class TimerReceiver extends BroadcastReceiver { + @Override + public void onReceive(Context context, Intent intent) { + if (intent.getAction() != null && Alert.ACTION_TIMER.equals(intent.getAction())) { + // Android 8+ requires foreground service to play sound reliably while backgrounded + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) { + context.startForegroundService(new Intent(context, TimerForegroundService.class)); + } else { + context.startService(new Intent(context, TimerForegroundService.class)); + } + } + } +}