diff --git a/CHANGES.md b/CHANGES.md index 4b78061..9878469 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,6 +2,15 @@ +## v2.3.0: Released at 2024-05-11 + +- Added shortcut to vibrate the current time +- Increased vibration intensity for Android versions >= 8 +- Fixed drifting alarms +- Updated to ViewPager2 and other small ui fixes + + + ## v2.2.0: Released at 2024-03-25 - Support for Android 14 (SDK 34) diff --git a/app/build.gradle b/app/build.gradle index ab32acb..0f2c6c6 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -31,8 +31,8 @@ android { minSdkVersion 16 targetSdkVersion 34 multiDexEnabled true - versionCode 10 - versionName '2.2.0' + versionCode 11 + versionName '2.3.0' // project website buildConfigField 'String', 'CONTACT_EMAIL_ADDRESS', '"tactile-clock@eric-scheibler.de"' buildConfigField 'String', 'PROJECT_WEBSITE', '"https://github.com/scheibler/TactileClock"' @@ -72,7 +72,7 @@ dependencies { implementation 'androidx.drawerlayout:drawerlayout:1.2.0' implementation 'androidx.fragment:fragment:1.6.2' implementation 'androidx.localbroadcastmanager:localbroadcastmanager:1.1.0' - implementation 'androidx.viewpager:viewpager:1.0.0' + implementation 'androidx.viewpager2:viewpager2:1.0.0' // material design implementation 'com.google.android.material:material:1.11.0' // guava diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 8e65320..eeeed07 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -23,13 +23,23 @@ + + + + + = android.os.Build.VERSION_CODES.LOLLIPOP) { + finishAndRemoveTask(); + } else { + finish(); + } + } + } + }; + +} diff --git a/app/src/main/java/de/eric_scheibler/tactileclock/ui/fragment/AbstractFragment.java b/app/src/main/java/de/eric_scheibler/tactileclock/ui/fragment/AbstractFragment.java deleted file mode 100644 index ae66b7f..0000000 --- a/app/src/main/java/de/eric_scheibler/tactileclock/ui/fragment/AbstractFragment.java +++ /dev/null @@ -1,51 +0,0 @@ -package de.eric_scheibler.tactileclock.ui.fragment; - -import androidx.fragment.app.DialogFragment; - - -public abstract class AbstractFragment extends DialogFragment { - - /** - * to be implemented - */ - - public abstract void fragmentVisible(); - public abstract void fragmentInvisible(); - - - /** - * pause and resume - */ - - private boolean isResumed = false; - private boolean isVisible = false; - - @Override public void onPause() { - super.onPause(); - if (getDialog() != null || isVisible) { // fragment is a dialog or embedded and visible - fragmentInvisible(); - } - isResumed = false; - } - - @Override public void onResume() { - super.onResume(); - if (getDialog() != null || isVisible) { // fragment is a dialog or embedded and visible - fragmentVisible(); - } - isResumed = true; - } - - @Override public void setUserVisibleHint(boolean isVisibleToUser) { - super.setUserVisibleHint(isVisibleToUser); - if (isResumed) { - if (isVisibleToUser) { - fragmentVisible(); - } else { - fragmentInvisible(); - } - } - isVisible = isVisibleToUser; - } - -} diff --git a/app/src/main/java/de/eric_scheibler/tactileclock/ui/fragment/PowerButtonFragment.java b/app/src/main/java/de/eric_scheibler/tactileclock/ui/fragment/PowerButtonFragment.java index 30a4d98..8ce0f21 100644 --- a/app/src/main/java/de/eric_scheibler/tactileclock/ui/fragment/PowerButtonFragment.java +++ b/app/src/main/java/de/eric_scheibler/tactileclock/ui/fragment/PowerButtonFragment.java @@ -17,9 +17,10 @@ import de.eric_scheibler.tactileclock.ui.dialog.SelectIntegerDialog.Token; import de.eric_scheibler.tactileclock.ui.dialog.SelectIntegerDialog; import de.eric_scheibler.tactileclock.utils.SettingsManager; +import androidx.fragment.app.Fragment; -public class PowerButtonFragment extends AbstractFragment implements IntegerSelector { +public class PowerButtonFragment extends Fragment implements IntegerSelector { // Store instance variables private SettingsManager settingsManagerInstance; @@ -34,8 +35,8 @@ public static PowerButtonFragment newInstance() { return powerButtonFragmentInstance; } - @Override public void onAttach(Context context) { - super.onAttach(context); + @Override public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); settingsManagerInstance = new SettingsManager(); } @@ -91,10 +92,12 @@ public void onClick(View view) { }); } - @Override public void fragmentInvisible() { + @Override public void onPause() { + super.onPause(); } - @Override public void fragmentVisible() { + @Override public void onResume() { + super.onResume(); updateUI(); } diff --git a/app/src/main/java/de/eric_scheibler/tactileclock/ui/fragment/WatchFragment.java b/app/src/main/java/de/eric_scheibler/tactileclock/ui/fragment/WatchFragment.java index 77c62ca..6b0fc7e 100644 --- a/app/src/main/java/de/eric_scheibler/tactileclock/ui/fragment/WatchFragment.java +++ b/app/src/main/java/de/eric_scheibler/tactileclock/ui/fragment/WatchFragment.java @@ -1,6 +1,6 @@ package de.eric_scheibler.tactileclock.ui.fragment; - +import android.app.AlarmManager; import android.content.Context; import android.os.Bundle; @@ -18,10 +18,17 @@ import de.eric_scheibler.tactileclock.ui.dialog.SelectIntegerDialog.Token; import de.eric_scheibler.tactileclock.ui.dialog.SelectIntegerDialog; import de.eric_scheibler.tactileclock.utils.SettingsManager; +import androidx.fragment.app.Fragment; +import android.annotation.TargetApi; +import android.os.Build; +import android.content.Intent; +import android.provider.Settings; +import de.eric_scheibler.tactileclock.utils.ApplicationInstance; +import timber.log.Timber; -public class WatchFragment extends AbstractFragment implements IntegerSelector { +public class WatchFragment extends Fragment implements IntegerSelector { // Store instance variables private SettingsManager settingsManagerInstance; @@ -36,8 +43,8 @@ public static WatchFragment newInstance() { return watchFragmentInstance; } - @Override public void onAttach(Context context) { - super.onAttach(context); + @Override public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); settingsManagerInstance = new SettingsManager(); } @@ -49,18 +56,7 @@ public static WatchFragment newInstance() { super.onViewCreated(view, savedInstanceState); buttonStartWatch = (Switch) view.findViewById(R.id.buttonStartWatch); - buttonStartWatch.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() { - @Override public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) { - if (settingsManagerInstance.isWatchEnabled() != isChecked) { - if (! settingsManagerInstance.isWatchEnabled()) { - settingsManagerInstance.enableWatch(); - } else { - settingsManagerInstance.disableWatch(); - } - updateUI(); - } - } - }); + buttonStartWatch.setOnCheckedChangeListener(null); buttonWatchInterval = (Button) view.findViewById(R.id.buttonWatchInterval); buttonWatchInterval.setOnClickListener(new View.OnClickListener() { @@ -102,10 +98,17 @@ public void onClick(View view) { }); } - @Override public void fragmentInvisible() { + @Override public void onPause() { + super.onPause(); } - @Override public void fragmentVisible() { + @Override public void onResume() { + super.onResume(); + if (settingsManagerInstance.isWatchEnabled() + && ! ApplicationInstance.canScheduleExactAlarms()) { + settingsManagerInstance.disableWatch(); + } + updateUI(); } @@ -125,6 +128,18 @@ public void onClick(View view) { private void updateUI() { buttonStartWatch.setChecked( settingsManagerInstance.isWatchEnabled()); + buttonStartWatch.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() { + @Override public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) { + if (settingsManagerInstance.isWatchEnabled() != isChecked) { + if (! settingsManagerInstance.isWatchEnabled()) { + tryToEnableWatch(); + } else { + settingsManagerInstance.disableWatch(); + } + updateUI(); + } + } + }); buttonWatchInterval.setText( String.format( @@ -150,4 +165,16 @@ private void updateUI() { buttonWatchAnnouncementVibration.setClickable(! settingsManagerInstance.isWatchEnabled()); } + @TargetApi(Build.VERSION_CODES.S) + private void tryToEnableWatch() { + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) { + if (! ApplicationInstance.canScheduleExactAlarms()) { + Intent intent = new Intent(Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM); + startActivity(intent); + return; + } + } + settingsManagerInstance.enableWatch(); + } + } diff --git a/app/src/main/java/de/eric_scheibler/tactileclock/utils/ApplicationInstance.java b/app/src/main/java/de/eric_scheibler/tactileclock/utils/ApplicationInstance.java index 4a99318..d895cc3 100644 --- a/app/src/main/java/de/eric_scheibler/tactileclock/utils/ApplicationInstance.java +++ b/app/src/main/java/de/eric_scheibler/tactileclock/utils/ApplicationInstance.java @@ -23,6 +23,8 @@ import android.annotation.SuppressLint; import android.os.Handler; import java.lang.Runnable; +import java.util.Calendar; +import android.os.SystemClock; @@ -48,7 +50,8 @@ public class ApplicationInstance extends Application { SettingsManager settingsManagerInstance = new SettingsManager(); boolean wasWatchEnabled = settingsManagerInstance.isWatchEnabled(); settingsManagerInstance.disableWatch(); - if (wasWatchEnabled) { + if (wasWatchEnabled + && ApplicationInstance.canScheduleExactAlarms()) { settingsManagerInstance.enableWatch(); } // update notivication @@ -99,10 +102,51 @@ private void createNotificationChannel() { */ private static final int PENDING_INTENT_VIBRATE_TIME_ID = 39128; + /** + * SCHEDULE_EXACT_ALARM permission + * only required for android 12 (api 31 / S) + * on Android 13 onwards the implicitly granted permission USE_EXACT_ALARM is used and canScheduleExactAlarms() is always true + */ + @TargetApi(Build.VERSION_CODES.S) + public static boolean canScheduleExactAlarms() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + return ((AlarmManager) ApplicationInstance.getContext().getSystemService(Context.ALARM_SERVICE)).canScheduleExactAlarms(); + } + return true; + } + + public boolean setAlarmAtFullHour(int hours) { + Calendar calendar = Calendar.getInstance(); + // at full hour + calendar.setTimeInMillis( + System.currentTimeMillis() + hours*60*60*1000l); + calendar.set(Calendar.MINUTE, 0); + calendar.set(Calendar.SECOND, 0); + return setAlarm(calendar); + } + + public boolean setAlarmAtFullMinute(int minutes) { + Calendar calendar = Calendar.getInstance(); + // at full minute + calendar.setTimeInMillis( + System.currentTimeMillis() + minutes*60*1000l); + calendar.set(Calendar.SECOND, 0); + return setAlarm(calendar); + } + @TargetApi(Build.VERSION_CODES.KITKAT) - public void setAlarm(long millisSinceDeviceStartup) { + @SuppressLint("MissingPermission") + private boolean setAlarm(Calendar calendar) { + if (! canScheduleExactAlarms()) { + return false; + } + + long millisSinceDeviceStartup = SystemClock.elapsedRealtime() + + Math.abs(calendar.getTimeInMillis() - System.currentTimeMillis()); + // create vibrate time pending intent PendingIntent pendingIntent = createActionVibrateTimeAndSetNextAlarmPendingIntent(); + // set alarm if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { alarmManager.setExactAndAllowWhileIdle( @@ -114,6 +158,7 @@ public void setAlarm(long millisSinceDeviceStartup) { alarmManager.set( AlarmManager.ELAPSED_REALTIME_WAKEUP, millisSinceDeviceStartup, pendingIntent); } + return true; } public void cancelAlarm() { diff --git a/app/src/main/java/de/eric_scheibler/tactileclock/utils/ScreenReceiver.java b/app/src/main/java/de/eric_scheibler/tactileclock/utils/ScreenReceiver.java index 764fef6..9b23f37 100644 --- a/app/src/main/java/de/eric_scheibler/tactileclock/utils/ScreenReceiver.java +++ b/app/src/main/java/de/eric_scheibler/tactileclock/utils/ScreenReceiver.java @@ -6,16 +6,19 @@ import androidx.core.content.ContextCompat; import timber.log.Timber; +import android.app.AlarmManager; public class ScreenReceiver extends BroadcastReceiver { @Override public void onReceive(Context context, Intent intent) { - Timber.d("action = %1$s", intent.getAction()); + Timber.d("onReceive: action = %1$s", intent.getAction()); + if (intent.getAction().equals(Intent.ACTION_BOOT_COMPLETED)) { Intent updateNotificationIntent = new Intent(context, TactileClockService.class); updateNotificationIntent.setAction(TactileClockService.ACTION_UPDATE_NOTIFICATION); ContextCompat.startForegroundService(context, updateNotificationIntent); + } else if (intent.getAction().equals(Intent.ACTION_SCREEN_ON) || intent.getAction().equals(Intent.ACTION_SCREEN_OFF)) { Intent screenOnOffIntent = new Intent(context, TactileClockService.class); diff --git a/app/src/main/java/de/eric_scheibler/tactileclock/utils/SettingsManager.java b/app/src/main/java/de/eric_scheibler/tactileclock/utils/SettingsManager.java index 2c132e4..ee3bea5 100644 --- a/app/src/main/java/de/eric_scheibler/tactileclock/utils/SettingsManager.java +++ b/app/src/main/java/de/eric_scheibler/tactileclock/utils/SettingsManager.java @@ -1,9 +1,7 @@ package de.eric_scheibler.tactileclock.utils; -import java.lang.Math; -import java.util.Calendar; import android.content.Context; import android.content.Intent; @@ -11,7 +9,6 @@ import android.content.SharedPreferences; import android.content.pm.PackageManager.NameNotFoundException; -import android.os.SystemClock; import android.preference.PreferenceManager; @@ -33,6 +30,7 @@ public class SettingsManager { private static final String KEY_RECENT_OPEN_TAB = "recentOpenTab"; private static final String KEY_HOUR_FORMAT = "hourFormat"; private static final String KEY_TIME_COMPONENT_ORDER = "timeComponentOrder"; + private static final String KEY_MAX_STRENGTH_VIBRATIONS_ENABLED = "maxStrengthVibrationsEnabled"; // power button private static final String KEY_POWER_BUTTON_SERVICE_ENABLED = "enableService"; private static final String KEY_POWER_BUTTON_ERROR_VIBRATION = "errorVibration"; @@ -50,6 +48,7 @@ public class SettingsManager { // general settings public static final boolean DEFAULT_FIRST_START = true; public static final boolean DEFAULT_ASKED_FOR_NOTIFICATION_PERMISSION = false; + public static final boolean DEFAULT_MAX_STRENGTH_VIBRATIONS_ENABLED = true; // power button public static final boolean DEFAULT_POWER_BUTTON_SERVICE_ENABLED = true; public static final boolean DEFAULT_POWER_BUTTON_ERROR_VIBRATION = true; @@ -139,6 +138,18 @@ public void setTimeComponentOrder(TimeComponentOrder timeComponentOrder) { editor.apply(); } + public boolean getMaxStrengthVibrationsEnabled() { + return settings.getBoolean( + KEY_MAX_STRENGTH_VIBRATIONS_ENABLED, + DEFAULT_MAX_STRENGTH_VIBRATIONS_ENABLED); + } + + public void setMaxStrengthVibrationsEnabled(boolean enabled) { + Editor editor = settings.edit(); + editor.putBoolean(KEY_MAX_STRENGTH_VIBRATIONS_ENABLED, enabled); + editor.apply(); + } + /** * power button @@ -220,20 +231,13 @@ public boolean isWatchEnabled() { public void enableWatch() { setWatchEnabled(true); // set first exact watch vibration alarm - Calendar calendar = Calendar.getInstance(); if (this.getWatchStartAtNextFullHour()) { // at next full hour - calendar.setTimeInMillis(System.currentTimeMillis() + 60*60*1000l); - calendar.set(Calendar.MINUTE, 0); - calendar.set(Calendar.SECOND, 0); + ((ApplicationInstance) ApplicationInstance.getContext()).setAlarmAtFullHour(1); } else { // at next full minute - calendar.setTimeInMillis(System.currentTimeMillis() + 60*1000l); - calendar.set(Calendar.SECOND, 0); + ((ApplicationInstance) ApplicationInstance.getContext()).setAlarmAtFullMinute(1); } - ((ApplicationInstance) ApplicationInstance.getContext()).setAlarm( - SystemClock.elapsedRealtime() - + Math.abs(calendar.getTimeInMillis() - System.currentTimeMillis())); } public void disableWatch() { diff --git a/app/src/main/java/de/eric_scheibler/tactileclock/utils/TactileClockService.java b/app/src/main/java/de/eric_scheibler/tactileclock/utils/TactileClockService.java index 50db482..6d67b1c 100644 --- a/app/src/main/java/de/eric_scheibler/tactileclock/utils/TactileClockService.java +++ b/app/src/main/java/de/eric_scheibler/tactileclock/utils/TactileClockService.java @@ -1,5 +1,7 @@ package de.eric_scheibler.tactileclock.utils; + import android.os.Handler; + import android.os.Looper; import java.util.Calendar; import android.annotation.TargetApi; @@ -17,7 +19,6 @@ import android.os.Build; import android.os.IBinder; -import android.os.SystemClock; import android.os.Vibrator; import androidx.core.app.NotificationCompat; @@ -32,12 +33,15 @@ import android.content.pm.ServiceInfo; import androidx.core.app.ServiceCompat; import android.app.ForegroundServiceStartNotAllowedException; +import android.os.VibrationEffect; +import androidx.localbroadcastmanager.content.LocalBroadcastManager; public class TactileClockService extends Service { // actions public static final String ACTION_UPDATE_NOTIFICATION = "de.eric_scheibler.tactileclock.action.update_notification"; + public static final String ACTION_VIBRATE_TIME = "de.eric_scheibler.tactileclock.action.vibrate_time"; public static final String ACTION_VIBRATE_TIME_AND_SET_NEXT_ALARM = "de.eric_scheibler.tactileclock.action.vibrate_time_and_set_next_alarm"; // vibrations @@ -45,11 +49,18 @@ public class TactileClockService extends Service { public static final long LONG_VIBRATION = 500; public static final long ERROR_VIBRATION = 1000; + // amplitudes + public static final int AMPLITUDE_DEFAULT = 150; + public static final int AMPLITUDE_MAX = 250; + // gaps public static final long SHORT_GAP = 250; public static final long MEDIUM_GAP = 750; public static final long LONG_GAP = 1250; + // broadcast responses + public static final String VIBRATION_FINISHED = "de.eric_scheibler.tactileclock.response.vibration_finished"; + // service vars private long lastActivation; private ApplicationInstance applicationInstance; @@ -68,6 +79,7 @@ public class TactileClockService extends Service { notificationManager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); settingsManagerInstance = new SettingsManager(); vibrator = (Vibrator) getSystemService(Context.VIBRATOR_SERVICE); + // register receiver that handles screen on and screen off logic // can't be done in manifest mScreenReceiver = new ScreenReceiver(); @@ -75,6 +87,11 @@ public class TactileClockService extends Service { filter.addAction(Intent.ACTION_SCREEN_ON); filter.addAction(Intent.ACTION_SCREEN_OFF); registerReceiver(mScreenReceiver, filter); + + if (settingsManagerInstance.isWatchEnabled() + && ! ApplicationInstance.canScheduleExactAlarms()) { + settingsManagerInstance.disableWatch(); + } } @Override public IBinder onBind(Intent intent) { @@ -87,8 +104,7 @@ public class TactileClockService extends Service { if (ACTION_UPDATE_NOTIFICATION.equals(intent.getAction())) { startForegroundService(); - if (! settingsManagerInstance.getPowerButtonServiceEnabled() - && ! settingsManagerInstance.isWatchEnabled()) { + if (shouldDestroyService()) { destroyService(); } @@ -102,7 +118,7 @@ public class TactileClockService extends Service { // double click detected // but screen was turned off and on instead of on and off // vibrate error message - vibrator.vibrate(ERROR_VIBRATION); + vibrateOnce(ERROR_VIBRATION); } lastActivation = System.currentTimeMillis(); @@ -119,6 +135,9 @@ public class TactileClockService extends Service { } lastActivation = System.currentTimeMillis(); + } else if (ACTION_VIBRATE_TIME.equals(intent.getAction())) { + vibrateTime(false, false); + } else if (ACTION_VIBRATE_TIME_AND_SET_NEXT_ALARM.equals(intent.getAction())) { // vibrate current time if (this.isVibrationAllowed()) { @@ -126,10 +145,10 @@ public class TactileClockService extends Service { settingsManagerInstance.getWatchAnnouncementVibration(), settingsManagerInstance.getWatchOnlyVibrateMinutes()); } + // set next alarm - applicationInstance.setAlarm( - SystemClock.elapsedRealtime() - + settingsManagerInstance.getWatchVibrationIntervalInMinutes()*60*1000l); + applicationInstance.setAlarmAtFullMinute( + settingsManagerInstance.getWatchVibrationIntervalInMinutes()); } } } @@ -161,17 +180,38 @@ private void startForegroundService() { destroyService(); } + private boolean shouldDestroyService() { + return ! settingsManagerInstance.getPowerButtonServiceEnabled() + && ! settingsManagerInstance.isWatchEnabled(); + } + private void destroyService() { notificationManager.cancel(NOTIFICATION_ID); stopForeground(true); stopSelf(); } + @TargetApi(Build.VERSION_CODES.O) + public void vibrateOnce(long duration) { + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) { + vibrator.vibrate( + VibrationEffect.createOneShot(duration, getAmplitude())); + } else { + vibrator.vibrate(duration); + } + } + + private int getAmplitude() { + return settingsManagerInstance.getMaxStrengthVibrationsEnabled() + ? AMPLITUDE_MAX : AMPLITUDE_DEFAULT; + } + /** * vibration pattern functions */ + @TargetApi(Build.VERSION_CODES.O) private void vibrateTime(boolean announcementVibration, boolean minutesOnly) { // get current time int hours, minutes; @@ -217,8 +257,32 @@ private void vibrateTime(boolean announcementVibration, boolean minutesOnly) { pattern = concat(pattern, getVibrationPatternForMinutes(minutes)); } + // total duration + long totalDuration = 0l; + for (long duration : pattern) { + totalDuration += duration; + } + // start vibration - vibrator.vibrate(pattern, -1); + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) { + int[] amplitudes = new int[pattern.length]; + for (int i=0; i - + + Tactile Clock + + Uhrzeit vibrieren Menü öffnen Menü schließen @@ -74,6 +76,7 @@ Einstellungen + Mit maximaler Intensität vibrieren Wähle zwischen 12 und 24 Stunden Zeitformat 12 Stunden Format 24 Stunden Format diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 930da0d..ddf9c5b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -7,6 +7,8 @@ Tactile Clock + + Vibrate time Open menu Close menu @@ -70,6 +72,7 @@ Settings + Vibrate with maximal intensity Choose between 12 and 24 hour time format 12 hour format 24 hour fourmat diff --git a/app/src/main/res/xml/shortcuts.xml b/app/src/main/res/xml/shortcuts.xml new file mode 100644 index 0000000..ab610ba --- /dev/null +++ b/app/src/main/res/xml/shortcuts.xml @@ -0,0 +1,17 @@ + + + + + + + +