18 Commits
Author SHA1 Message Date
me 025836acb6 Agent fixed the current set of issues, now the app at least runs (and is actually staying alive in the background successfully!) but the countdown is visually messed up on SDK24 (hours field is missing) and SDK36 (fields do not count down at all even though the app is making the noise), plus the Pause button no longer pauses and causes the right button to be disabled indefinitely after using it. 2026-11-09 15:13:08 -07:00
me 9ff779b26c Agent thinks it fixed the crash on SDK36 and lack of interval counting down on SDK24. 2026-11-08 19:38:16 -07:00
me 432a1394b9 main, app is now worse off after agent experimentation, crashes immediately on SDK 36 and does not count down on SDK 24, turn on DEBUG to see if anything obvious and silly is happening. 2026-11-08 15:21:04 -07:00
me 3a057f0878 Agent says it now has the app working properly with a foreground service. 2026-11-08 15:03:46 -07:00
me f59bd78716 main, more Android Studio recommendations. 2026-11-06 14:38:21 -07:00
me 01472d90fb Update AGP. 2026-11-06 14:35:02 -07:00
me f0cd128025 main, take some suggestions given by Android Studio. 2026-11-06 14:31:07 -07:00
me b7a419eeab main, fix some silliness of agent using full class path. 2026-11-06 14:30:34 -07:00
me 4ede74e7a2 Agent's attempt to ensure the sound gets played every interval (adding Looper to Handler and moving release() from playSound to onDestroy) as well as ensuring it's not delayed (using play() rather than start()). 2026-11-06 13:35:08 -07:00
me 124e1bc211 Fix some of the agent's code, mediaplayer not defined in TimerForegroundService so maybe it was intended for Main, and PowerManager's WakeLock tag wanted to know the app name with a colon for logging. 2026-11-03 13:34:31 -07:00
me 1b9f40c002 Update plugin versions. 2026-11-03 13:32:39 -07:00
me 262aa439db Model's attempt to fix: 1) Only the first itnerval making a sound, all subsequent countdowns being silent. 2) App opening up directly to the Alarms & Reminders screen to get permissions approved without any prompting the user on what is going on. 2026-11-03 13:16:32 -07:00
me 9f1bfe8940 gradle, add but comment changes recommended by local model. 2026-11-01 06:27:18 -07:00
me 1099dec4e6 main, fix NullPointerException. 2026-11-01 06:22:48 -07:00
me 54418801c5 main, add missing import. 2026-10-28 17:36:01 -07:00
me 875d143076 More fixes trying to get the new changes to work. 2026-10-23 08:37:53 -07:00
me 595cffce78 main, add missing or deleted import. 2026-10-21 03:00:15 -07:00
me 3965aa6d88 Attempt to get app to work properly when in the background in newer versions of Android, with assistance from qwen3.6:35b. Testing TBD. 2026-10-20 18:39:07 -07:00
9 changed files with 421 additions and 249 deletions

No files matched your search

+2 -2
View File
@@ -2,11 +2,11 @@ apply plugin: 'com.android.application'
android { android {
namespace "com.hyperling.apps.infinitetimer" namespace "com.hyperling.apps.infinitetimer"
compileSdkVersion 36 compileSdkVersion 37
defaultConfig { defaultConfig {
applicationId "com.hyperling.apps.infinitetimer" applicationId "com.hyperling.apps.infinitetimer"
minSdkVersion 14 minSdkVersion 14
targetSdkVersion 36 targetSdkVersion 37
versionCode 9 versionCode 9
versionName "1.1.2" versionName "1.1.2"
testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
+17 -6
View File
@@ -1,6 +1,15 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- 🔊 Required to play sound while backgrounded & bypass Doze -->
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<!-- Android 14 requires declaring foreground service types -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<!-- ⏰ Android 12+ exact alarm permission (users can deny, code handles it gracefully) -->
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
<application <application
android:allowBackup="true" android:allowBackup="true"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
@@ -8,6 +17,7 @@
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/InfiniteTimer" android:theme="@style/InfiniteTimer"
android:configChanges="keyboard|keyboardHidden|orientation|screenLayout|uiMode|screenSize|smallestScreenSize"> android:configChanges="keyboard|keyboardHidden|orientation|screenLayout|uiMode|screenSize|smallestScreenSize">
<activity android:name=".MainActivity" android:exported="true"> <activity android:name=".MainActivity" android:exported="true">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
@@ -15,13 +25,14 @@
</intent-filter> </intent-filter>
</activity> </activity>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <!-- 🔔 Register the receiver that triggers the alarm response -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" /> <receiver android:name=".TimerReceiver"
android:exported="false" />
<service
android:name=".Alert" <!-- 🎵 Transient service to play audio & hold WakeLock -->
<service android:name=".TimerForegroundService"
android:foregroundServiceType="mediaPlayback" android:foregroundServiceType="mediaPlayback"
android:exported="false" /> android:exported="false" />
</application>
</application>
</manifest> </manifest>
@@ -1,125 +1,85 @@
package com.hyperling.apps.infinitetimer; package com.hyperling.apps.infinitetimer;
import android.app.Notification; import android.app.AlarmManager;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.app.PendingIntent; import android.app.PendingIntent;
import android.app.Service; import android.content.Context;
import android.content.Intent; import android.content.Intent;
import android.media.MediaPlayer;
import android.media.RingtoneManager;
import android.net.Uri;
import android.os.Build; import android.os.Build;
import android.os.Handler; import android.provider.Settings;
import android.os.IBinder;
import android.os.Looper;
import android.support.v4.app.NotificationCompat;
import android.util.Log; 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; // FLAG_IMMUTABLE required for Android 12+ (API 31+)
String TAG = "Alert.", tag; int flags = PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_CANCEL_CURRENT;
private static final String CHANNEL_ID = "alert_service_channel"; if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
private static final int NOTIFICATION_ID = 1; flags |= PendingIntent.FLAG_IMMUTABLE;
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);
} }
createNotificationChannel(); PendingIntent pendingIntent = PendingIntent.getBroadcast(context, 0, intent, flags);
startForeground(NOTIFICATION_ID, buildNotification()); AlarmManager alarmManager = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE);
// Start the alert loop (your original Runnable logic) if (alarmManager != null) {
handler.post(alertRunnable); long triggerTime = System.currentTimeMillis() + intervalMs;
// Exact alarm ensures it triggers even in Doze mode
return START_STICKY; if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
} alarmManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerTime, pendingIntent);
} else {
private void playAlert() { alarmManager.setExact(AlarmManager.RTC_WAKEUP, triggerTime, pendingIntent);
// 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 (mediaPlayer == null) { Log.d(TAG, "Alarm set for " + intervalMs + "ms at " + triggerTime);
if (DEBUG) Log.d(tag, "Media Player is null, trying ringtone"); } else {
ringtoneUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_RINGTONE); Log.e(TAG, "Could not get AlarmManager service");
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();
} }
} }
@Override /**
public void onDestroy() { * Cancels the timer. If the app is killed, alarms are dropped automatically.
super.onDestroy(); */
handler.removeCallbacks(alertRunnable); public static void stop(Context context) {
if (mediaPlayer != null) { Intent intent = new Intent(context, TimerReceiver.class);
mediaPlayer.release(); int flags = PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_CANCEL_CURRENT;
mediaPlayer = null; 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) { * Checks/Requests permission for exact alarms on Android 12+
return null; */
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); * Helper to request exact alarm permission if missing
PendingIntent pendingIntent = PendingIntent.getActivity( */
this, 0, notificationIntent, public static void requestExactAlarmPermission(android.app.Activity activity) {
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && !isExactAlarmAllowed(activity)) {
); Intent intent = new Intent(Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM);
activity.startActivity(intent);
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);
} }
} }
} }
@@ -1,17 +1,23 @@
package com.hyperling.apps.infinitetimer; package com.hyperling.apps.infinitetimer;
import android.app.NotificationManager; import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.DialogInterface; import android.content.DialogInterface;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.SharedPreferences; import android.content.SharedPreferences;
import android.graphics.Color; import android.graphics.Color;
import android.media.AudioManager; import android.media.AudioManager;
import android.media.MediaPlayer; import android.media.MediaPlayer;
import android.media.Ringtone; import android.media.Ringtone;
import android.media.RingtoneManager;
import android.net.Uri; import android.net.Uri;
import android.os.Build;
import android.os.Bundle; import android.os.Bundle;
import android.os.Handler; import android.os.Handler;
import android.os.Looper;
import android.os.Message; import android.os.Message;
import android.provider.Settings;
import android.support.annotation.NonNull;
import android.support.v7.app.AlertDialog; import android.support.v7.app.AlertDialog;
import android.support.v7.app.AppCompatActivity; import android.support.v7.app.AppCompatActivity;
import android.util.Log; import android.util.Log;
@@ -24,7 +30,6 @@ import android.widget.SeekBar;
import android.widget.TableRow; import android.widget.TableRow;
import android.widget.TextView; import android.widget.TextView;
import java.net.URI;
import java.util.Map; import java.util.Map;
public class MainActivity extends AppCompatActivity { public class MainActivity extends AppCompatActivity {
@@ -40,7 +45,7 @@ public class MainActivity extends AppCompatActivity {
// Interrupt based checking would be nice // Interrupt based checking would be nice
int waitInterval = 99; int waitInterval = 99;
boolean DEBUG, start, appOpen, serviceRunning; boolean DEBUG=true, start, appOpen, serviceRunning;
SharedPreferences sharedPreferences; SharedPreferences sharedPreferences;
@@ -52,12 +57,21 @@ public class MainActivity extends AppCompatActivity {
TextView tvDot; TextView tvDot;
Uri ringtoneUri; Uri ringtoneUri;
// RingtoneManager reference for consistent notification sound playback across loops
private android.media.Ringtone notificationRingtone;
// For proper sound lifecycle management across timer loops
private Ringtone ringtonePlayer;
MediaPlayer mediaPlayer; MediaPlayer mediaPlayer;
SeekBar seekBar; SeekBar seekBar;
AudioManager audioManager; AudioManager audioManager;
TextView tvSeekBar; TextView tvSeekBar;
// Local broadcast receiver to update UI while app is foregrounded
private BroadcastReceiver timerUpdateReceiver;
@Override @Override
protected void onCreate(Bundle savedInstanceState) { protected void onCreate(Bundle savedInstanceState) {
String tag = "onCreate"; String tag = "onCreate";
@@ -82,6 +96,9 @@ public class MainActivity extends AppCompatActivity {
if (DEBUG) Log.d(tag, "Got strings."); if (DEBUG) Log.d(tag, "Got strings.");
/***** Setup Local Broadcast Receiver *****/
setupTimerUpdateReceiver();
/***** Shared Preferences *****/ /***** Shared Preferences *****/
sharedPreferences = getSharedPreferences(keySharedPreferences, MODE_PRIVATE); sharedPreferences = getSharedPreferences(keySharedPreferences, MODE_PRIVATE);
DEBUG = sharedPreferences.getBoolean(keyDebug, true); DEBUG = sharedPreferences.getBoolean(keyDebug, true);
@@ -91,48 +108,19 @@ public class MainActivity extends AppCompatActivity {
if (DEBUG) Log.d(tag, "Got preferences."); if (DEBUG) Log.d(tag, "Got preferences.");
// Sound // Sound
ringtoneUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION); // Check exact alarm permission for Android 12+
if (DEBUG) Log.d(tag, "ringtoneUri=" + ringtoneUri.toString()); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && !Alert.isExactAlarmAllowed(this)) {
mediaPlayer = MediaPlayer.create(this, ringtoneUri); if (DEBUG) Log.w(tag, "Exact alarm permission not granted, requesting...");
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) new AlertDialog.Builder(MainActivity.this)
.setTitle("Error") .setTitle("Alarm Permission Required")
.setMessage(message) .setMessage("To ensure the timer alerts you reliably, please grant 'Alarms & Reminders' permission in system settings.")
.setPositiveButton("Exit", new DialogInterface.OnClickListener() { .setPositiveButton("Grant Permission", (d, w) -> {
@Override Intent intent = new Intent(Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM);
public void onClick(DialogInterface dialog, int which) { startActivity(intent);
dialog.dismiss();
finish();
}
}) })
.setNegativeButton("Not Now", (d, w) -> d.dismiss())
.setCancelable(false) .setCancelable(false)
.show() .show();
;
} else {
if (DEBUG) Log.d(tag, "mediaPlayer=" + mediaPlayer.toString());
} }
if (DEBUG){ if (DEBUG){
@@ -240,6 +228,20 @@ public class MainActivity extends AppCompatActivity {
if (DEBUG) Log.d(tag, "Finished"); 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(){ private void doTimeChooser(){
String tag = TAG + "doTimeChooser"; String tag = TAG + "doTimeChooser";
if (DEBUG) Log.d(tag, "Starting"); if (DEBUG) Log.d(tag, "Starting");
@@ -372,17 +374,29 @@ public class MainActivity extends AppCompatActivity {
SharedPreferences.Editor editor = sharedPreferences.edit(); SharedPreferences.Editor editor = sharedPreferences.edit();
editor.putBoolean(keyStartStop, start); editor.putBoolean(keyStartStop, start);
editor.putBoolean(keyAppOpen, true); editor.putBoolean(keyAppOpen, true);
editor.putBoolean(keyServiceRunning, false);
editor.apply(); editor.apply();
// hours:minutes:seconds should be disabled during count down. // hours:minutes:seconds should be disabled during count down.
flipInputTexts(); flipInputTexts();
if (buttonText.equals(getString(R.string.btnStart))){ if (buttonText.equals(getString(R.string.btnStart))){
incrementTime.sendEmptyMessage(0);
setLoopInterval(); setLoopInterval();
resetTimeLeft(); resetTimeLeft();
setStartTime();
long interval = longLoopInterval > 1000 ? longLoopInterval : 60000; // Minimum 1 minute interval for alarms
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);
// Pass the calculated `interval` (which includes the 60s fallback if user entered nothing)
svcIntent.putExtra(TimerForegroundService.EXTRA_INTERVAL_MS, interval);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
startForegroundService(svcIntent);
} else {
startService(svcIntent);
}
} }
else if (buttonText.equals(getString(R.string.btnPause))){ else if (buttonText.equals(getString(R.string.btnPause))){
btnStartStop.setText(getString(R.string.btnResume)); btnStartStop.setText(getString(R.string.btnResume));
@@ -391,13 +405,11 @@ public class MainActivity extends AppCompatActivity {
etMinutes.setEnabled(false); etMinutes.setEnabled(false);
etSeconds.setEnabled(false); etSeconds.setEnabled(false);
} }
else if (buttonText.equals(getString(R.string.btnStop))){ else if (buttonText.equals(getString(R.string.btnStop)) || buttonText.equals(getString(R.string.btnResume))){
setChronometer(longLoopInterval); // Stop the Foreground Service and cancel alarms
} Intent svcIntent = new Intent(this, TimerForegroundService.class);
else if (buttonText.equals(getString(R.string.btnResume))){ stopService(svcIntent);
setStartTime(); Alert.stop(this);
incrementTime.sendEmptyMessage(0);
btnResetPause.setEnabled(true);
} }
flipStartStopButton(); flipStartStopButton();
@@ -494,79 +506,7 @@ public class MainActivity extends AppCompatActivity {
if (DEBUG) Log.d(tag, "Finished"); if (DEBUG) Log.d(tag, "Finished");
} }
private final Handler incrementTime = new Handler(){ // Removed local countdown Handler - timing and background survival is now delegated to TimerForegroundService
@Override
public void handleMessage(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
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");
}
};
Thread t = new Thread(task);
t.start();
}
if (DEBUG) Log.d(tag, "Finished");
}
};
private void setStartTime(){ private void setStartTime(){
String tag = TAG + "setStartTime"; String tag = TAG + "setStartTime";
@@ -616,15 +556,36 @@ public class MainActivity extends AppCompatActivity {
String tag = TAG + "playSound"; String tag = TAG + "playSound";
if (DEBUG) Log.d(tag, "Starting"); if (DEBUG) Log.d(tag, "Starting");
/* Original code, did not work on Nexus 7 with CM 12.1-20151117 (Android 5.1.1) * /
Uri uri = Uri.parse("android.resource://" + getPackageName() + "/raw/chime_sound_7143");
//Ringtone ringtone = RingtoneManager.getRingtone(getApplicationContext(), ringtoneUri);
Ringtone ringtone = RingtoneManager.getRingtone(getApplicationContext(), uri);
ringtone.play();
/**/
try { try {
mediaPlayer.start(); // Stop any existing notification ringtone first to prevent overlap
if (notificationRingtone != null && notificationRingtone.isPlaying()) {
notificationRingtone.stop();
}
// Use RingtoneManager which handles async playback lifecycle properly
Uri uri = android.media.RingtoneManager.getDefaultUri(android.media.RingtoneManager.TYPE_NOTIFICATION);
notificationRingtone = android.media.RingtoneManager.getRingtone(this, uri);
if (notificationRingtone != null) {
// Ringtone.play() is asynchronous and handles its own lifecycle,
// avoiding the MediaPlayer.create() + immediate release bug that
// caused silence on subsequent loops
notificationRingtone.play();
} else {
// Fallback: if getRingtone fails, try direct MediaPlayer with proper lifecycle
MediaPlayer localPlayer = MediaPlayer.create(this, uri);
if (localPlayer != null) {
localPlayer.start();
final MediaPlayer fp = localPlayer;
// Release after playback completes (use delay as a safety net)
new android.os.Handler(android.os.Looper.getMainLooper()).postDelayed(
new Runnable() { @Override public void run() {
if (fp.isPlaying()) fp.stop();
fp.release();
}}, 2000
);
}
}
} catch (Exception e) { } catch (Exception e) {
sharedPreferences.edit().putBoolean(keyStartStop, false).apply(); sharedPreferences.edit().putBoolean(keyStartStop, false).apply();
@@ -665,6 +626,9 @@ public class MainActivity extends AppCompatActivity {
String tag = TAG + "resetAll"; String tag = TAG + "resetAll";
if (DEBUG) Log.d(tag, "Starting"); if (DEBUG) Log.d(tag, "Starting");
// Stop the background alert service when resetting
Alert.stop(this);
if (DEBUG) Log.d(tag, "Resetting screen values..."); if (DEBUG) Log.d(tag, "Resetting screen values...");
setChronometer(0); setChronometer(0);
setEditTexts(""); setEditTexts("");
@@ -706,6 +670,8 @@ public class MainActivity extends AppCompatActivity {
protected void onPause() { protected void onPause() {
String tag = TAG + "onPause"; String tag = TAG + "onPause";
if (DEBUG) Log.d(tag, "Starting"); if (DEBUG) Log.d(tag, "Starting");
unregisterReceiver(timerUpdateReceiver);
stringLoopInterval = stringLoopInterval =
etHours.getText().toString() + ":" + etHours.getText().toString() + ":" +
@@ -736,6 +702,14 @@ public class MainActivity extends AppCompatActivity {
recoverScreen(); recoverScreen();
syncVolume(); syncVolume();
// Register receiver to get live updates from ForegroundService while app is visible
IntentFilter filter = new IntentFilter(TimerForegroundService.class.getName() + ".UPDATE");
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
registerReceiver(timerUpdateReceiver, filter, Context.RECEIVER_NOT_EXPORTED);
} else {
registerReceiver(timerUpdateReceiver, filter);
}
if (DEBUG) Log.d(tag, "Finished"); if (DEBUG) Log.d(tag, "Finished");
} }
@@ -745,7 +719,14 @@ public class MainActivity extends AppCompatActivity {
String tag = TAG + "onDestroy"; String tag = TAG + "onDestroy";
if (DEBUG) Log.d(tag, "Starting"); if (DEBUG) Log.d(tag, "Starting");
resetAll(); // Stop the background alert service when destroying activity
Alert.stop(this);
if (mediaPlayer != null) { mediaPlayer.release(); mediaPlayer = null; }
// Clean up ringtone player to prevent resource leak across config changes
if (notificationRingtone != null && notificationRingtone.isPlaying()) {
notificationRingtone.stop();
}
if (DEBUG) Log.d(tag, "Done with local code, calling super.."); if (DEBUG) Log.d(tag, "Done with local code, calling super..");
@@ -0,0 +1,188 @@
package com.hyperling.apps.infinitetimer;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.app.Service;
import android.content.Intent;
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";
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();
// 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: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) {
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;
tick(); // Start the countdown immediately when the service is launched
} else {
Log.w(TAG, "Received start command without a valid positive interval.");
}
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
);
// 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(String textContent) {
return new NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle("Infinite Timer")
.setContentText(textContent)
.setSmallIcon(android.R.drawable.ic_lock_idle_alarm)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setOngoing(true)
.setStyle(new NotificationCompat.InboxStyle().addLine(textContent))
.build();
}
// --- 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
public IBinder onBind(Intent intent) { return null; }
@Override
public void onDestroy() {
super.onDestroy();
stopTimer();
if (ringtonePlayer != null && ringtonePlayer.isPlaying()) ringtonePlayer.stop();
Log.d(TAG, "Service destroyed.");
}
}
@@ -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));
}
}
}
}
+1 -1
View File
@@ -13,7 +13,7 @@ buildscript {
} }
} }
dependencies { dependencies {
classpath 'com.android.tools.build:gradle:9.4.1' classpath 'com.android.tools.build:gradle:9.5.1'
// NOTE: Do not place your application dependencies here; they belong // NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files // in the individual module build.gradle files
+13 -1
View File
@@ -21,8 +21,20 @@ android.uniquePackageNames=false
android.useAndroidX=false android.useAndroidX=false
android.usesSdkInManifest.disallowed=false android.usesSdkInManifest.disallowed=false
org.gradle.jvmargs=-Xmx1536m org.gradle.jvmargs=-Xmx1536m
### Changes recommended by qwen3.6:35b when DNS was failing to foojay.
##org.gradle.jvmargs=-Xmx1536m -Dorg.gradle.jvm.download.auto.install=false
##org.gradle.java.home=/usr/lib/jvm/java-21-openjdk
##
### Toolchain & Vendor Workarounds for missing foojay.io DNS resolution
##org.gradle.java.installations.paths=/usr/lib/jvm/java-21-openjdk
##kotlin.compiler.ignoreJdkVendor=true
### Suppress AGP deprecation warnings
##android.sync.suppressAgpWarnings=UNSUPPORTED_PROJECT_OPTION_USE
##android.generateSyncIssueWhenLibraryConstraintsAreEnabled=false
##android.sync.suppressAgpWarnings=LIBRARY_CONSTRAINTS_SHOULD_BE_DISABLED
##kotlin.compiler.execution.strategy=in-process
# When configured, Gradle will run in incubating parallel mode. # When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit # This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec-decoupled_projects
# org.gradle.parallel=true # org.gradle.parallel=true
+1 -1
View File
@@ -1,6 +1,6 @@
#Fri Jul 11 12:55:34 MST 2025 #Fri Jul 11 12:55:34 MST 2025
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.0-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.2-bin.zip
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists