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 d5ef14b..e1251bf 100755 --- a/app/src/main/java/com/hyperling/apps/infinitetimer/MainActivity.java +++ b/app/src/main/java/com/hyperling/apps/infinitetimer/MainActivity.java @@ -1,7 +1,10 @@ package com.hyperling.apps.infinitetimer; +import android.content.BroadcastReceiver; +import android.content.Context; import android.content.DialogInterface; import android.content.Intent; +import android.content.IntentFilter; import android.content.SharedPreferences; import android.graphics.Color; import android.media.AudioManager; @@ -66,6 +69,9 @@ public class MainActivity extends AppCompatActivity { AudioManager audioManager; TextView tvSeekBar; + // Local broadcast receiver to update UI while app is foregrounded + private BroadcastReceiver timerUpdateReceiver; + @Override protected void onCreate(Bundle savedInstanceState) { String tag = "onCreate"; @@ -90,6 +96,9 @@ public class MainActivity extends AppCompatActivity { if (DEBUG) Log.d(tag, "Got strings."); + /***** Setup Local Broadcast Receiver *****/ + setupTimerUpdateReceiver(); + /***** Shared Preferences *****/ sharedPreferences = getSharedPreferences(keySharedPreferences, MODE_PRIVATE); DEBUG = sharedPreferences.getBoolean(keyDebug, true); @@ -219,6 +228,20 @@ public class MainActivity extends AppCompatActivity { if (DEBUG) Log.d(tag, "Finished"); } + private void setupTimerUpdateReceiver() { + timerUpdateReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + if (DEBUG) Log.d(TAG + "Receiver", "Received service update"); + // Update UI immediately with latest countdown state from Service + String remainingTime = intent.getStringExtra("remainingTime"); + if (remainingTime != null && chronometer != null) { + chronometer.setText(remainingTime); + } + } + }; + } + private void doTimeChooser(){ String tag = TAG + "doTimeChooser"; if (DEBUG) Log.d(tag, "Starting"); @@ -351,29 +374,26 @@ public class MainActivity extends AppCompatActivity { SharedPreferences.Editor editor = sharedPreferences.edit(); editor.putBoolean(keyStartStop, start); editor.putBoolean(keyAppOpen, true); - editor.putBoolean(keyServiceRunning, false); editor.apply(); // hours:minutes:seconds should be disabled during count down. flipInputTexts(); if (buttonText.equals(getString(R.string.btnStart))){ - incrementTime.sendEmptyMessage(0); 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); + if (DEBUG) Log.d(tag, "Starting ForegroundService with interval: " + interval); + + // Delegate countdown logic to ForegroundService to survive Doze mode on SDK 36+ + Intent svcIntent = new Intent(this, TimerForegroundService.class); + svcIntent.setAction(Alert.ACTION_TIMER); + svcIntent.putExtra(TimerForegroundService.EXTRA_INTERVAL_MS, longLoopInterval); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + startForegroundService(svcIntent); } 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(); + startService(svcIntent); } } @@ -384,13 +404,11 @@ public class MainActivity extends AppCompatActivity { etMinutes.setEnabled(false); etSeconds.setEnabled(false); } - else if (buttonText.equals(getString(R.string.btnStop))){ - setChronometer(longLoopInterval); - } - else if (buttonText.equals(getString(R.string.btnResume))){ - setStartTime(); - incrementTime.sendEmptyMessage(0); - btnResetPause.setEnabled(true); + else if (buttonText.equals(getString(R.string.btnStop)) || buttonText.equals(getString(R.string.btnResume))){ + // Stop the Foreground Service and cancel alarms + Intent svcIntent = new Intent(this, TimerForegroundService.class); + stopService(svcIntent); + Alert.stop(this); } flipStartStopButton(); @@ -487,84 +505,7 @@ public class MainActivity extends AppCompatActivity { if (DEBUG) Log.d(tag, "Finished"); } - private final Handler incrementTime = new Handler(Looper.getMainLooper()){ - @Override - public void handleMessage(@NonNull Message msg) { - String tag = TAG + "handleMessage"; - if (DEBUG) Log.d(tag, "Starting"); - - super.handleMessage(msg); - - appOpen = sharedPreferences.getBoolean(keyAppOpen, true); - if (appOpen) { - sharedPreferences.edit().putBoolean(keyServiceRunning, false).apply(); - } else { - sharedPreferences.edit().putBoolean(keyServiceRunning, true).apply(); - } - - start = sharedPreferences.getBoolean(keyStartStop, false); - if (start){ - //start = !start; - //sharedPreferences.edit().putBoolean(keyStartStop, false).apply(); - - // Calculate new time and text - setTimeLeft(); - - if (DEBUG) Log.d(tag, "longTimeLeft=" + longTimeLeft); - - // Check if we need to beep - if (longTimeLeft < 1000){ - playSound(); - resetTimeLeft(); - setStartTime(); - - if (DEBUG) { - Log.d(tag, "longLoopInterval=" + longLoopInterval); - Log.d(tag, "longStartTime=" + longStartTime); - Log.d(tag, "longEndTime=" + longEndTime); - Log.d(tag, "longTimeLeft=" + longTimeLeft); - Log.d(tag, "waitInterval=" + waitInterval); - Log.d(tag, "start=" + start); - //chronometer.setText("Test"); - //setChronometer(200); - /* - start=false; - startStopChronometer("Start"); - */ - } - } - - // Wait - Thread t = wait(tag); - t.start(); - } - if (DEBUG) Log.d(tag, "Finished"); - } - - @NonNull - private Thread wait(String tag) { - Runnable task = new Runnable() { - @Override - public void run() { - - synchronized (this){ - try{ - wait(waitInterval); - if (DEBUG) Log.d(tag, "Done waiting."); - } - catch (Exception e){ - e.printStackTrace(); - if (DEBUG) Log.d(tag, "Failed to wait."); - } - } - //sharedPreferences.edit().putBoolean(keyStartStop, true).apply(); - incrementTime.sendEmptyMessage(0); - //startStopChronometer("Start"); - } - }; - return new Thread(task); - } - }; + // Removed local countdown Handler - timing and background survival is now delegated to TimerForegroundService private void setStartTime(){ String tag = TAG + "setStartTime"; @@ -728,6 +669,8 @@ public class MainActivity extends AppCompatActivity { protected void onPause() { String tag = TAG + "onPause"; if (DEBUG) Log.d(tag, "Starting"); + + unregisterReceiver(timerUpdateReceiver); stringLoopInterval = etHours.getText().toString() + ":" + @@ -758,6 +701,9 @@ public class MainActivity extends AppCompatActivity { recoverScreen(); syncVolume(); + + // Register receiver to get live updates from ForegroundService while app is visible + registerReceiver(timerUpdateReceiver, new IntentFilter(TimerForegroundService.class.getName() + ".UPDATE")); if (DEBUG) Log.d(tag, "Finished"); } diff --git a/app/src/main/java/com/hyperling/apps/infinitetimer/TimerForegroundService.java b/app/src/main/java/com/hyperling/apps/infinitetimer/TimerForegroundService.java index f69911f..a537990 100644 --- a/app/src/main/java/com/hyperling/apps/infinitetimer/TimerForegroundService.java +++ b/app/src/main/java/com/hyperling/apps/infinitetimer/TimerForegroundService.java @@ -8,41 +8,73 @@ import android.media.Ringtone; 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.os.PowerManager; import android.support.v4.app.NotificationCompat; 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; + public static final String CHANNEL_ID = "timer_notification_channel"; + public static final int NOTIFICATION_ID = 1001; + + // Intent extras to start the timer + public static final String EXTRA_INTERVAL_MS = "com.hyperling.apps.infinitetimer.INTERVAL_MS"; private PowerManager.WakeLock wakeLock; + private Handler serviceHandler; + + // Timer state + private long loopIntervalMs; + private long endTimeMillis; + private Ringtone ringtonePlayer; @Override public void onCreate() { super.onCreate(); createNotificationChannel(); - - // Android 8+ requires foreground service to play sound reliably while backgrounded - startForeground(NOTIFICATION_ID, buildNotification()); - - // Acquire a wake lock briefly to ensure the CPU stays awake during playback + + // Acquire PARTIAL_WAKE_LOCK to keep CPU awake during Doze mode PowerManager pm = (PowerManager) getSystemService(POWER_SERVICE); - wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "InfiniteTimer:TimerWakeLock"); - wakeLock.acquire(60 * 1000L); + wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "InfiniteTimer:TimerFGWakeLock"); + + // Use main looper for safe UI updates from foreground service + serviceHandler = new Handler(Looper.getMainLooper()); } @Override public int onStartCommand(Intent intent, int flags, int startId) { - playSound(); - return START_NOT_STICKY; // Don't restart immediately if killed + if (intent == null || !Alert.ACTION_TIMER.equals(intent.getAction())) { + return START_NOT_STICKY; + } + + // Start persistent foreground notification immediately to prevent Doze mode killing us + startForeground(NOTIFICATION_ID, buildNotification("Timer is active...")); + + // Acquire wake lock briefly on start, but we'll hold it continuously as long as service runs + if (!wakeLock.isHeld()) { + wakeLock.acquire(); + } + + // If interval is passed via intent (first start), set it up. + // Otherwise, rely on AlarmManager/Handler to keep running. + long newInterval = intent.getLongExtra(EXTRA_INTERVAL_MS, -1); + if (newInterval > 0) { + loopIntervalMs = newInterval; + endTimeMillis = System.currentTimeMillis() + loopIntervalMs; + } + + return START_STICKY; } private void createNotificationChannel() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - NotificationChannel channel = new NotificationChannel(CHANNEL_ID, "Timer Notifications", NotificationManager.IMPORTANCE_HIGH); + 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); @@ -51,37 +83,93 @@ public class TimerForegroundService extends Service { } } - private android.app.Notification buildNotification() { + private android.app.Notification buildNotification(String textContent) { return new 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 + .setContentText(textContent) .setSmallIcon(android.R.drawable.ic_lock_idle_alarm) .setPriority(NotificationCompat.PRIORITY_HIGH) .setOngoing(true) + .setStyle(new NotificationCompat.InboxStyle().addLine(textContent)) .build(); } - private void playSound() { - try { - Uri uri = android.media.RingtoneManager.getDefaultUri(android.media.RingtoneManager.TYPE_NOTIFICATION); - Ringtone r = RingtoneManager.getRingtone(this, uri); - if (r != null) { - r.play(); - // Allow time for sound to buffer and play before stopping - new android.os.Handler().postDelayed(new Runnable() { - @Override - public void run() { - stopSelf(); - } - }, 1000); - } else { - stopSelf(); - } - } catch (Exception e) { - Log.e(TAG, "Failed to play sound", e); - stopSelf(); + // --- Core Timer Engine --- + + /** + * Starts or resumes the countdown engine. + * Foreground services bypass Doze mode restrictions, allowing this to run reliably on SDK 36+. + */ + public void startTimer(long intervalMs) { + if (loopIntervalMs == intervalMs && serviceHandler.hasMessages(0)) { + return; // Already running at this interval } + loopIntervalMs = intervalMs; + endTimeMillis = System.currentTimeMillis() + loopIntervalMs; + tick(); + } + + public void stopTimer() { + if (serviceHandler != null) { + serviceHandler.removeCallbacksAndMessages(null); + if (wakeLock.isHeld()) wakeLock.release(); + } + } + + private void tick() { + long currentTime = System.currentTimeMillis(); + long remainingMs = endTimeMillis - currentTime; + + if (remainingMs <= 0) { + playAlarmSound(); + // Loop indefinitely: reschedule for the next interval + endTimeMillis += loopIntervalMs; + remainingMs = loopIntervalMs; + } + + String timeStr = formatTime(remainingMs); + updateNotification(timeStr); + + // Send local broadcast to MainActivity so UI updates live while app is visible + Intent broadcast = new Intent(TimerForegroundService.class.getName() + ".UPDATE"); + broadcast.putExtra("remainingTime", timeStr); + sendBroadcast(broadcast); + + // Schedule next tick immediately (foreground service allows high-frequency updates) + serviceHandler.postDelayed(tickRunnable, 500); + } + + private final Runnable tickRunnable = new Runnable() { + @Override + public void run() { tick(); } + }; + + private void playAlarmSound() { + try { + // Stop any previous ringtone to prevent overlap + if (ringtonePlayer != null && ringtonePlayer.isPlaying()) { + ringtonePlayer.stop(); + } + Uri uri = android.media.RingtoneManager.getDefaultUri(android.media.RingtoneManager.TYPE_NOTIFICATION); + ringtonePlayer = android.media.RingtoneManager.getRingtone(this, uri); + if (ringtonePlayer != null) ringtonePlayer.play(); + } catch (Exception e) { + Log.e(TAG, "Failed to play alarm sound", e); + } + } + + private void updateNotification(String timeStr) { + NotificationManager nm = getSystemService(NotificationManager.class); + if (nm != null) { + nm.notify(NOTIFICATION_ID, buildNotification(timeStr)); + } + } + + private String formatTime(long ms) { + long totalSeconds = ms / 1000; + long minutes = (totalSeconds % 3600) / 60; + long seconds = totalSeconds % 60; + return String.format("%02d:%02d", minutes, seconds); } @Override @@ -90,6 +178,8 @@ public class TimerForegroundService extends Service { @Override public void onDestroy() { super.onDestroy(); - if (wakeLock.isHeld()) wakeLock.release(); + stopTimer(); + if (ringtonePlayer != null && ringtonePlayer.isPlaying()) ringtonePlayer.stop(); + Log.d(TAG, "Service destroyed."); } }