diff --git a/app/build.gradle b/app/build.gradle index a16d3c7b..e37f813b 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -54,6 +54,16 @@ android { checkReleaseBuilds false abortOnError false } + + compileOptions { + sourceCompatibility = 1.8 + targetCompatibility = 1.8 + } + + kotlinOptions { + jvmTarget = "1.8" + freeCompilerArgs = ["-XXLanguage:+NewInference"] + } } dependencies { @@ -61,4 +71,10 @@ dependencies { implementation 'com.facebook.stetho:stetho:1.5.0' implementation 'androidx.constraintlayout:constraintlayout:2.0.0-beta2' implementation 'com.shawnlin:number-picker:2.4.6' + implementation "androidx.preference:preference:1.1.0" + implementation "androidx.work:work-runtime-ktx:2.3.2" + implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.3' + implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.2.0' + implementation 'org.greenrobot:eventbus:3.2.0' + implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0' } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 2f44e935..00fb3475 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -79,6 +79,7 @@ + diff --git a/app/src/main/kotlin/com/simplemobiletools/clock/App.kt b/app/src/main/kotlin/com/simplemobiletools/clock/App.kt index 42965fee..268b0a11 100644 --- a/app/src/main/kotlin/com/simplemobiletools/clock/App.kt +++ b/app/src/main/kotlin/com/simplemobiletools/clock/App.kt @@ -1,16 +1,110 @@ package com.simplemobiletools.clock import android.app.Application +import android.app.NotificationManager +import android.content.Context +import android.os.Build +import android.os.CountDownTimer +import androidx.annotation.RequiresApi +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleObserver +import androidx.lifecycle.OnLifecycleEvent +import androidx.lifecycle.ProcessLifecycleOwner import com.facebook.stetho.Stetho +import com.simplemobiletools.clock.extensions.config +import com.simplemobiletools.clock.extensions.getOpenTimerTabIntent +import com.simplemobiletools.clock.extensions.getTimerNotification +import com.simplemobiletools.clock.helpers.TIMER_NOTIF_ID +import com.simplemobiletools.clock.services.TimerState +import com.simplemobiletools.clock.services.TimerStopService +import com.simplemobiletools.clock.services.startTimerService import com.simplemobiletools.commons.extensions.checkUseEnglish +import org.greenrobot.eventbus.EventBus +import org.greenrobot.eventbus.Subscribe +import org.greenrobot.eventbus.ThreadMode + +class App : Application(), LifecycleObserver { + + private var timer: CountDownTimer? = null + private var lastTick = 0L -class App : Application() { override fun onCreate() { super.onCreate() + ProcessLifecycleOwner.get().lifecycle.addObserver(this) + EventBus.getDefault().register(this) + if (BuildConfig.DEBUG) { Stetho.initializeWithDefaults(this) } checkUseEnglish() } + + override fun onTerminate() { + EventBus.getDefault().unregister(this) + super.onTerminate() + } + + @RequiresApi(Build.VERSION_CODES.O) + @OnLifecycleEvent(Lifecycle.Event.ON_STOP) + private fun onAppBackgrounded() { + if (config.timerState is TimerState.Running) { + startTimerService(this) + } + } + + @OnLifecycleEvent(Lifecycle.Event.ON_START) + private fun onAppForegrounded() { + EventBus.getDefault().post(TimerStopService) + } + + @Subscribe(threadMode = ThreadMode.MAIN) + fun onMessageEvent(state: TimerState.Idle) { + config.timerState = state + timer?.cancel() + } + + @Subscribe(threadMode = ThreadMode.MAIN) + fun onMessageEvent(state: TimerState.Start) { + timer = object : CountDownTimer(state.duration, 1000) { + override fun onTick(tick: Long) { + lastTick = tick + + val newState = TimerState.Running(state.duration, tick) + EventBus.getDefault().post(newState) + config.timerState = newState + } + + override fun onFinish() { + EventBus.getDefault().post(TimerState.Finish(state.duration)) + EventBus.getDefault().post(TimerStopService) + } + }.start() + } + + @Subscribe(threadMode = ThreadMode.MAIN) + fun onMessageEvent(event: TimerState.Finish) { + val pendingIntent = getOpenTimerTabIntent() + val notification = getTimerNotification(pendingIntent, false) //MAYBE IN FUTURE ADD TIME TO NOTIFICATION + val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + notificationManager.notify(TIMER_NOTIF_ID, notification) + + EventBus.getDefault().post(TimerState.Finished) + } + + @Subscribe(threadMode = ThreadMode.MAIN) + fun onMessageEvent(state: TimerState.Finished) { + config.timerState = state + } + + @Subscribe(threadMode = ThreadMode.MAIN) + fun onMessageEvent(event: TimerState.Pause) { + EventBus.getDefault().post(TimerState.Paused(event.duration, lastTick)) + } + + @Subscribe(threadMode = ThreadMode.MAIN) + fun onMessageEvent(state: TimerState.Paused) { + config.timerState = state + timer?.cancel() + } } diff --git a/app/src/main/kotlin/com/simplemobiletools/clock/extensions/Context.kt b/app/src/main/kotlin/com/simplemobiletools/clock/extensions/Context.kt index b2023013..574adec8 100644 --- a/app/src/main/kotlin/com/simplemobiletools/clock/extensions/Context.kt +++ b/app/src/main/kotlin/com/simplemobiletools/clock/extensions/Context.kt @@ -6,6 +6,7 @@ import android.appwidget.AppWidgetManager import android.content.ComponentName import android.content.Context import android.content.Intent +import android.content.SharedPreferences import android.media.AudioAttributes import android.media.AudioManager import android.net.Uri @@ -15,6 +16,7 @@ import android.text.style.RelativeSizeSpan import android.widget.Toast import androidx.core.app.AlarmManagerCompat import androidx.core.app.NotificationCompat +import androidx.preference.PreferenceManager import com.simplemobiletools.clock.R import com.simplemobiletools.clock.activities.ReminderActivity import com.simplemobiletools.clock.activities.SnoozeReminderActivity @@ -224,13 +226,6 @@ fun Context.showAlarmNotification(alarm: Alarm) { scheduleNextAlarm(alarm, false) } -fun Context.showTimerNotification(addDeleteIntent: Boolean) { - val pendingIntent = getOpenTimerTabIntent() - val notification = getTimerNotification(pendingIntent, addDeleteIntent) - val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - notificationManager.notify(TIMER_NOTIF_ID, notification) -} - @SuppressLint("NewApi") fun Context.getTimerNotification(pendingIntent: PendingIntent, addDeleteIntent: Boolean): Notification { var soundUri = config.timerSoundUri diff --git a/app/src/main/kotlin/com/simplemobiletools/clock/extensions/Fragment.kt b/app/src/main/kotlin/com/simplemobiletools/clock/extensions/Fragment.kt new file mode 100644 index 00000000..6fe261a6 --- /dev/null +++ b/app/src/main/kotlin/com/simplemobiletools/clock/extensions/Fragment.kt @@ -0,0 +1,10 @@ +package com.simplemobiletools.clock.extensions + +import android.content.SharedPreferences +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentActivity +import androidx.preference.PreferenceManager + +val Fragment.requiredActivity: FragmentActivity get() = this.activity!! + +val Fragment.preferences: SharedPreferences get() = PreferenceManager.getDefaultSharedPreferences(requiredActivity) diff --git a/app/src/main/kotlin/com/simplemobiletools/clock/extensions/Int.kt b/app/src/main/kotlin/com/simplemobiletools/clock/extensions/Int.kt new file mode 100644 index 00000000..56c7f19c --- /dev/null +++ b/app/src/main/kotlin/com/simplemobiletools/clock/extensions/Int.kt @@ -0,0 +1,6 @@ +package com.simplemobiletools.clock.extensions + +import java.util.concurrent.TimeUnit + +val Int.secondsToMillis get() = TimeUnit.SECONDS.toMillis(this.toLong()) +val Int.millisToSeconds get() = TimeUnit.MILLISECONDS.toSeconds(this.toLong()) diff --git a/app/src/main/kotlin/com/simplemobiletools/clock/extensions/Logs.kt b/app/src/main/kotlin/com/simplemobiletools/clock/extensions/Logs.kt new file mode 100644 index 00000000..4a662681 --- /dev/null +++ b/app/src/main/kotlin/com/simplemobiletools/clock/extensions/Logs.kt @@ -0,0 +1,7 @@ +package com.simplemobiletools.clock.extensions + +import android.util.Log +import com.simplemobiletools.clock.BuildConfig + +fun A.log(tag: String) = apply { if (BuildConfig.DEBUG) Log.wtf(tag, this.toString()) } +fun A.log(first: String, tag: String) = apply { if (BuildConfig.DEBUG) Log.wtf(tag, first) } diff --git a/app/src/main/kotlin/com/simplemobiletools/clock/extensions/Long.kt b/app/src/main/kotlin/com/simplemobiletools/clock/extensions/Long.kt index 2b29f682..7500e536 100644 --- a/app/src/main/kotlin/com/simplemobiletools/clock/extensions/Long.kt +++ b/app/src/main/kotlin/com/simplemobiletools/clock/extensions/Long.kt @@ -1,5 +1,7 @@ package com.simplemobiletools.clock.extensions +import android.text.format.DateFormat +import java.util.* import java.util.concurrent.TimeUnit fun Long.formatStopwatchTime(useLongerMSFormat: Boolean): String { @@ -27,3 +29,13 @@ fun Long.formatStopwatchTime(useLongerMSFormat: Boolean): String { } } } + +fun Long.timestampFormat(format: String = "dd. MM. yyyy"): String { + val calendar = Calendar.getInstance(Locale.getDefault()) + calendar.timeInMillis = this + + return DateFormat.format(format, calendar).toString() +} + +val Long.secondsToMillis get() = TimeUnit.SECONDS.toMillis(this) +val Long.millisToSeconds get() = TimeUnit.MILLISECONDS.toSeconds(this) diff --git a/app/src/main/kotlin/com/simplemobiletools/clock/extensions/gson/RuntimeTypeAdapterFactory.java b/app/src/main/kotlin/com/simplemobiletools/clock/extensions/gson/RuntimeTypeAdapterFactory.java new file mode 100644 index 00000000..ad5ce3d0 --- /dev/null +++ b/app/src/main/kotlin/com/simplemobiletools/clock/extensions/gson/RuntimeTypeAdapterFactory.java @@ -0,0 +1,267 @@ +/* + * Copyright (C) 2011 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.simplemobiletools.clock.extensions.gson; + +import com.google.gson.Gson; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParseException; +import com.google.gson.JsonPrimitive; +import com.google.gson.TypeAdapter; +import com.google.gson.TypeAdapterFactory; +import com.google.gson.internal.Streams; +import com.google.gson.reflect.TypeToken; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonWriter; + +import java.io.IOException; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Adapts values whose runtime type may differ from their declaration type. This + * is necessary when a field's type is not the same type that GSON should create + * when deserializing that field. For example, consider these types: + *
   {@code
+ *   abstract class Shape {
+ *     int x;
+ *     int y;
+ *   }
+ *   class Circle extends Shape {
+ *     int radius;
+ *   }
+ *   class Rectangle extends Shape {
+ *     int width;
+ *     int height;
+ *   }
+ *   class Diamond extends Shape {
+ *     int width;
+ *     int height;
+ *   }
+ *   class Drawing {
+ *     Shape bottomShape;
+ *     Shape topShape;
+ *   }
+ * }
+ *

Without additional type information, the serialized JSON is ambiguous. Is + * the bottom shape in this drawing a rectangle or a diamond?

   {@code
+ *   {
+ *     "bottomShape": {
+ *       "width": 10,
+ *       "height": 5,
+ *       "x": 0,
+ *       "y": 0
+ *     },
+ *     "topShape": {
+ *       "radius": 2,
+ *       "x": 4,
+ *       "y": 1
+ *     }
+ *   }}
+ * This class addresses this problem by adding type information to the + * serialized JSON and honoring that type information when the JSON is + * deserialized:
   {@code
+ *   {
+ *     "bottomShape": {
+ *       "type": "Diamond",
+ *       "width": 10,
+ *       "height": 5,
+ *       "x": 0,
+ *       "y": 0
+ *     },
+ *     "topShape": {
+ *       "type": "Circle",
+ *       "radius": 2,
+ *       "x": 4,
+ *       "y": 1
+ *     }
+ *   }}
+ * Both the type field name ({@code "type"}) and the type labels ({@code + * "Rectangle"}) are configurable. + * + *

Registering Types

+ * Create a {@code RuntimeTypeAdapterFactory} by passing the base type and type field + * name to the {@link #of} factory method. If you don't supply an explicit type + * field name, {@code "type"} will be used.
   {@code
+ *   RuntimeTypeAdapterFactory shapeAdapterFactory
+ *       = RuntimeTypeAdapterFactory.of(Shape.class, "type");
+ * }
+ * Next register all of your subtypes. Every subtype must be explicitly + * registered. This protects your application from injection attacks. If you + * don't supply an explicit type label, the type's simple name will be used. + *
   {@code
+ *   shapeAdapterFactory.registerSubtype(Rectangle.class, "Rectangle");
+ *   shapeAdapterFactory.registerSubtype(Circle.class, "Circle");
+ *   shapeAdapterFactory.registerSubtype(Diamond.class, "Diamond");
+ * }
+ * Finally, register the type adapter factory in your application's GSON builder: + *
   {@code
+ *   Gson gson = new GsonBuilder()
+ *       .registerTypeAdapterFactory(shapeAdapterFactory)
+ *       .create();
+ * }
+ * Like {@code GsonBuilder}, this API supports chaining:
   {@code
+ *   RuntimeTypeAdapterFactory shapeAdapterFactory = RuntimeTypeAdapterFactory.of(Shape.class)
+ *       .registerSubtype(Rectangle.class)
+ *       .registerSubtype(Circle.class)
+ *       .registerSubtype(Diamond.class);
+ * }
+ */ +public final class RuntimeTypeAdapterFactory implements TypeAdapterFactory { + private final Class baseType; + private final String typeFieldName; + private final Map> labelToSubtype = new LinkedHashMap>(); + private final Map, String> subtypeToLabel = new LinkedHashMap, String>(); + private final boolean maintainType; + + private RuntimeTypeAdapterFactory(Class baseType, String typeFieldName, boolean maintainType) { + if (typeFieldName == null || baseType == null) { + throw new NullPointerException(); + } + this.baseType = baseType; + this.typeFieldName = typeFieldName; + this.maintainType = maintainType; + } + + /** + * Creates a new runtime type adapter using for {@code baseType} using {@code + * typeFieldName} as the type field name. Type field names are case sensitive. + * {@code maintainType} flag decide if the type will be stored in pojo or not. + */ + public static RuntimeTypeAdapterFactory of(Class baseType, String typeFieldName, boolean maintainType) { + return new RuntimeTypeAdapterFactory(baseType, typeFieldName, maintainType); + } + + /** + * Creates a new runtime type adapter using for {@code baseType} using {@code + * typeFieldName} as the type field name. Type field names are case sensitive. + */ + public static RuntimeTypeAdapterFactory of(Class baseType, String typeFieldName) { + return new RuntimeTypeAdapterFactory(baseType, typeFieldName, false); + } + + /** + * Creates a new runtime type adapter for {@code baseType} using {@code "type"} as + * the type field name. + */ + public static RuntimeTypeAdapterFactory of(Class baseType) { + return new RuntimeTypeAdapterFactory(baseType, "type", false); + } + + /** + * Registers {@code type} identified by {@code label}. Labels are case + * sensitive. + * + * @throws IllegalArgumentException if either {@code type} or {@code label} + * have already been registered on this type adapter. + */ + public RuntimeTypeAdapterFactory registerSubtype(Class type, String label) { + if (type == null || label == null) { + throw new NullPointerException(); + } + if (subtypeToLabel.containsKey(type) || labelToSubtype.containsKey(label)) { + throw new IllegalArgumentException("types and labels must be unique"); + } + labelToSubtype.put(label, type); + subtypeToLabel.put(type, label); + return this; + } + + /** + * Registers {@code type} identified by its {@link Class#getSimpleName simple + * name}. Labels are case sensitive. + * + * @throws IllegalArgumentException if either {@code type} or its simple name + * have already been registered on this type adapter. + */ + public RuntimeTypeAdapterFactory registerSubtype(Class type) { + return registerSubtype(type, type.getSimpleName()); + } + + public TypeAdapter create(Gson gson, TypeToken type) { + if (type.getRawType() != baseType) { + return null; + } + + final Map> labelToDelegate + = new LinkedHashMap>(); + final Map, TypeAdapter> subtypeToDelegate + = new LinkedHashMap, TypeAdapter>(); + for (Map.Entry> entry : labelToSubtype.entrySet()) { + TypeAdapter delegate = gson.getDelegateAdapter(this, TypeToken.get(entry.getValue())); + labelToDelegate.put(entry.getKey(), delegate); + subtypeToDelegate.put(entry.getValue(), delegate); + } + + return new TypeAdapter() { + @Override + public R read(JsonReader in) throws IOException { + JsonElement jsonElement = Streams.parse(in); + JsonElement labelJsonElement; + if (maintainType) { + labelJsonElement = jsonElement.getAsJsonObject().get(typeFieldName); + } else { + labelJsonElement = jsonElement.getAsJsonObject().remove(typeFieldName); + } + + if (labelJsonElement == null) { + throw new JsonParseException("cannot deserialize " + baseType + + " because it does not define a field named " + typeFieldName); + } + String label = labelJsonElement.getAsString(); + @SuppressWarnings("unchecked") // registration requires that subtype extends T + TypeAdapter delegate = (TypeAdapter) labelToDelegate.get(label); + if (delegate == null) { + throw new JsonParseException("cannot deserialize " + baseType + " subtype named " + + label + "; did you forget to register a subtype?"); + } + return delegate.fromJsonTree(jsonElement); + } + + @Override + public void write(JsonWriter out, R value) throws IOException { + Class srcType = value.getClass(); + String label = subtypeToLabel.get(srcType); + @SuppressWarnings("unchecked") // registration requires that subtype extends T + TypeAdapter delegate = (TypeAdapter) subtypeToDelegate.get(srcType); + if (delegate == null) { + throw new JsonParseException("cannot serialize " + srcType.getName() + + "; did you forget to register a subtype?"); + } + JsonObject jsonObject = delegate.toJsonTree(value).getAsJsonObject(); + + if (maintainType) { + Streams.write(jsonObject, out); + return; + } + + JsonObject clone = new JsonObject(); + + if (jsonObject.has(typeFieldName)) { + throw new JsonParseException("cannot serialize " + srcType.getName() + + " because it already defines a field named " + typeFieldName); + } + clone.add(typeFieldName, new JsonPrimitive(label)); + + for (Map.Entry e : jsonObject.entrySet()) { + clone.add(e.getKey(), e.getValue()); + } + Streams.write(clone, out); + } + }.nullSafe(); + } +} diff --git a/app/src/main/kotlin/com/simplemobiletools/clock/extensions/gson/TypeAdapter.kt b/app/src/main/kotlin/com/simplemobiletools/clock/extensions/gson/TypeAdapter.kt new file mode 100644 index 00000000..20df992a --- /dev/null +++ b/app/src/main/kotlin/com/simplemobiletools/clock/extensions/gson/TypeAdapter.kt @@ -0,0 +1,23 @@ +package com.simplemobiletools.clock.extensions.gson + +import com.google.gson.Gson +import com.google.gson.GsonBuilder +import com.google.gson.TypeAdapterFactory +import com.simplemobiletools.clock.services.TimerState + +val timerStates = valueOf() + .registerSubtype(TimerState.Idle::class.java) + .registerSubtype(TimerState.Start::class.java) + .registerSubtype(TimerState.Running::class.java) + .registerSubtype(TimerState.Pause::class.java) + .registerSubtype(TimerState.Paused::class.java) + .registerSubtype(TimerState.Finish::class.java) + .registerSubtype(TimerState.Finished::class.java) + +inline fun valueOf(): RuntimeTypeAdapterFactory = RuntimeTypeAdapterFactory.of(T::class.java) + +fun GsonBuilder.registerTypes(vararg types: TypeAdapterFactory) = apply { + types.forEach { registerTypeAdapterFactory(it) } +} + +val gson: Gson = GsonBuilder().registerTypes(timerStates).create() diff --git a/app/src/main/kotlin/com/simplemobiletools/clock/fragments/TimerFragment.kt b/app/src/main/kotlin/com/simplemobiletools/clock/fragments/TimerFragment.kt index 2709b901..e6561845 100644 --- a/app/src/main/kotlin/com/simplemobiletools/clock/fragments/TimerFragment.kt +++ b/app/src/main/kotlin/com/simplemobiletools/clock/fragments/TimerFragment.kt @@ -1,164 +1,50 @@ package com.simplemobiletools.clock.fragments -import android.annotation.TargetApi -import android.app.Notification -import android.app.NotificationChannel -import android.app.NotificationManager -import android.content.Context -import android.content.Intent import android.graphics.Color import android.media.AudioManager -import android.os.Build import android.os.Bundle -import android.os.Handler -import android.os.SystemClock import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import androidx.core.app.NotificationCompat import androidx.fragment.app.Fragment import com.simplemobiletools.clock.R -import com.simplemobiletools.clock.activities.ReminderActivity import com.simplemobiletools.clock.activities.SimpleActivity import com.simplemobiletools.clock.dialogs.MyTimePickerDialogDialog import com.simplemobiletools.clock.extensions.* import com.simplemobiletools.clock.helpers.PICK_AUDIO_FILE_INTENT_ID -import com.simplemobiletools.clock.helpers.TIMER_NOTIF_ID +import com.simplemobiletools.clock.services.TimerState import com.simplemobiletools.commons.dialogs.SelectAlarmSoundDialog import com.simplemobiletools.commons.extensions.* import com.simplemobiletools.commons.helpers.ALARM_SOUND_TYPE_ALARM -import com.simplemobiletools.commons.helpers.isOreoPlus import com.simplemobiletools.commons.models.AlarmSound import kotlinx.android.synthetic.main.fragment_timer.view.* +import org.greenrobot.eventbus.EventBus +import org.greenrobot.eventbus.Subscribe +import org.greenrobot.eventbus.ThreadMode +import kotlin.math.roundToInt class TimerFragment : Fragment() { - private val UPDATE_INTERVAL = 1000L - private val WAS_RUNNING = "was_running" - private val CURRENT_TICKS = "current_ticks" - private val TOTAL_TICKS = "total_ticks" - - private var isRunning = false - private var uptimeAtStart = 0L - private var initialSecs = 0 - private var totalTicks = 0 - private var currentTicks = 0 - private var updateHandler = Handler() - private var isForegrounded = true lateinit var view: ViewGroup - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - val config = context!!.config - view = (inflater.inflate(R.layout.fragment_timer, container, false) as ViewGroup).apply { - timer_time.setOnClickListener { - togglePlayPause() - } - - timer_play_pause.setOnClickListener { - togglePlayPause() - } - - timer_reset.setOnClickListener { - context!!.hideTimerNotification() - resetTimer() - } - - timer_initial_time.setOnClickListener { - MyTimePickerDialogDialog(activity as SimpleActivity, config.timerSeconds) { - val seconds = if (it <= 0) 10 else it - config.timerSeconds = seconds - timer_initial_time.text = seconds.getFormattedDuration() - if (!isRunning) { - resetTimer() - } - } - } - - timer_vibrate_holder.setOnClickListener { - timer_vibrate.toggle() - config.timerVibrate = timer_vibrate.isChecked - } - - timer_sound.setOnClickListener { - SelectAlarmSoundDialog(activity as SimpleActivity, config.timerSoundUri, AudioManager.STREAM_ALARM, PICK_AUDIO_FILE_INTENT_ID, - ALARM_SOUND_TYPE_ALARM, true, onAlarmPicked = { - if (it != null) { - updateAlarmSound(it) - } - }, onAlarmSoundDeleted = { - if (config.timerSoundUri == it.uri) { - val defaultAlarm = context.getDefaultAlarmSound(ALARM_SOUND_TYPE_ALARM) - updateAlarmSound(defaultAlarm) - } - context.checkAlarmsWithDeletedSoundUri(it.uri) - }) - } - } - - initialSecs = config.timerSeconds - updateDisplayedText() - return view - } - - override fun onStart() { - super.onStart() - isForegrounded = true - } - - override fun onResume() { - super.onResume() - setupViews() - } - - override fun onStop() { - super.onStop() - isForegrounded = false - context!!.hideNotification(TIMER_NOTIF_ID) + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + EventBus.getDefault().register(this) } override fun onDestroy() { + EventBus.getDefault().unregister(this) super.onDestroy() - if (isRunning && activity?.isChangingConfigurations == false) { - context?.toast(R.string.timer_stopped) - } - isRunning = false - updateHandler.removeCallbacks(updateRunnable) } - override fun onSaveInstanceState(outState: Bundle) { - outState.apply { - putBoolean(WAS_RUNNING, isRunning) - putInt(TOTAL_TICKS, totalTicks) - putInt(CURRENT_TICKS, currentTicks) - } - super.onSaveInstanceState(outState) - } + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + view = (inflater.inflate(R.layout.fragment_timer, container, false) as ViewGroup).apply { + val config = requiredActivity.config + val textColor = config.textColor - override fun onViewStateRestored(savedInstanceState: Bundle?) { - super.onViewStateRestored(savedInstanceState) - savedInstanceState?.apply { - isRunning = getBoolean(WAS_RUNNING, false) - totalTicks = getInt(TOTAL_TICKS, 0) - currentTicks = getInt(CURRENT_TICKS, 0) + timer_time.text = 0.getFormattedDuration() - if (isRunning) { - uptimeAtStart = SystemClock.uptimeMillis() - currentTicks * UPDATE_INTERVAL - updateTimerState(false) - } - } - } - - fun updateAlarmSound(alarmSound: AlarmSound) { - context!!.config.timerSoundTitle = alarmSound.title - context!!.config.timerSoundUri = alarmSound.uri - view.timer_sound.text = alarmSound.title - } - - private fun setupViews() { - val config = context!!.config - val textColor = config.textColor - view.apply { - context!!.updateTextColors(timer_fragment) + requiredActivity.updateTextColors(timer_fragment) timer_play_pause.background = resources.getColoredDrawableWithColor(R.drawable.circle_background_filled, context!!.getAdjustedPrimaryColor()) timer_reset.applyColorFilter(textColor) @@ -170,119 +56,126 @@ class TimerFragment : Fragment() { timer_sound.text = config.timerSoundTitle timer_sound.colorLeftDrawable(textColor) - } - updateIcons() - updateDisplayedText() - } - - private fun togglePlayPause() { - isRunning = !isRunning - updateTimerState(true) - } - - private fun updateTimerState(setUptimeAtStart: Boolean) { - updateIcons() - context!!.hideTimerNotification() - - if (isRunning) { - updateHandler.post(updateRunnable) - view.timer_reset.beVisible() - if (setUptimeAtStart) { - uptimeAtStart = SystemClock.uptimeMillis() + timer_time.setOnClickListener { + stopTimer() + } + + timer_play_pause.setOnClickListener { + val state = config.timerState + + when (state) { + is TimerState.Idle -> { + EventBus.getDefault().post(TimerState.Start(config.timerSeconds.secondsToMillis)) + } + + is TimerState.Paused -> { + EventBus.getDefault().post(TimerState.Start(state.tick)) + } + + is TimerState.Running -> { + EventBus.getDefault().post(TimerState.Pause(state.tick)) + } + + is TimerState.Finished -> { + EventBus.getDefault().post(TimerState.Start(config.timerSeconds.secondsToMillis)) + } + + else -> {} + } + } + + timer_reset.setOnClickListener { + stopTimer() + } + + timer_initial_time.setOnClickListener { + MyTimePickerDialogDialog(activity as SimpleActivity, config.timerSeconds) { seconds -> + val timerSeconds = if (seconds <= 0) 10 else seconds + config.timerSeconds = timerSeconds + timer_initial_time.text = timerSeconds.getFormattedDuration() + } + } + + timer_vibrate_holder.setOnClickListener { + timer_vibrate.toggle() + config.timerVibrate = timer_vibrate.isChecked + } + + timer_sound.setOnClickListener { + SelectAlarmSoundDialog(activity as SimpleActivity, config.timerSoundUri, AudioManager.STREAM_ALARM, PICK_AUDIO_FILE_INTENT_ID, + ALARM_SOUND_TYPE_ALARM, true, + onAlarmPicked = { sound -> + if (sound != null) { + updateAlarmSound(sound) + } + }, + onAlarmSoundDeleted = { sound -> + if (config.timerSoundUri == sound.uri) { + val defaultAlarm = context.getDefaultAlarmSound(ALARM_SOUND_TYPE_ALARM) + updateAlarmSound(defaultAlarm) + } + + context.checkAlarmsWithDeletedSoundUri(sound.uri) + }) } - } else { - updateHandler.removeCallbacksAndMessages(null) - currentTicks = 0 - totalTicks-- } + + return view } - private fun updateIcons() { - val drawableId = if (isRunning) R.drawable.ic_pause_vector else R.drawable.ic_play_vector - val iconColor = if (context!!.getAdjustedPrimaryColor() == Color.WHITE) Color.BLACK else context!!.config.textColor + private fun stopTimer() { + EventBus.getDefault().post(TimerState.Idle) + requiredActivity.hideTimerNotification() + requiredActivity.toast(R.string.timer_stopped) + } + + @Subscribe(threadMode = ThreadMode.MAIN) + fun onMessageEvent(state: TimerState.Idle) { + view.timer_time.text = 0.getFormattedDuration() + updateViewStates(state) + } + + @Subscribe(threadMode = ThreadMode.MAIN) + fun onMessageEvent(state: TimerState.Running) { + view.timer_time.text = state.tick.div(1000F).roundToInt().getFormattedDuration() + updateViewStates(state) + } + + @Subscribe(threadMode = ThreadMode.MAIN) + fun onMessageEvent(state: TimerState.Paused) { + updateViewStates(state) + } + + @Subscribe(threadMode = ThreadMode.MAIN) + fun onMessageEvent(state: TimerState.Finished) { + view.timer_time.text = 0.getFormattedDuration() + updateViewStates(state) + } + + private fun updateViewStates(state: TimerState) { + + val resetPossible = state is TimerState.Running || state is TimerState.Paused || state is TimerState.Finished + view.timer_reset.beVisibleIf(resetPossible) + + val drawableId = if (state is TimerState.Running) { + R.drawable.ic_pause_vector + } else { + R.drawable.ic_play_vector + } + + val iconColor = if (requiredActivity.getAdjustedPrimaryColor() == Color.WHITE) { + Color.BLACK + } else { + requiredActivity.config.textColor + } + view.timer_play_pause.setImageDrawable(resources.getColoredDrawableWithColor(drawableId, iconColor)) } - private fun resetTimer() { - updateHandler.removeCallbacks(updateRunnable) - isRunning = false - currentTicks = 0 - totalTicks = 0 - initialSecs = context!!.config.timerSeconds - updateDisplayedText() - updateIcons() - view.timer_reset.beGone() - } - - private fun updateDisplayedText(): Boolean { - val diff = initialSecs - totalTicks - var formattedDuration = Math.abs(diff).getFormattedDuration() - - if (diff < 0) { - formattedDuration = "-$formattedDuration" - if (!isForegrounded) { - resetTimer() - return false - } - } - - view.timer_time.text = formattedDuration - if (diff == 0) { - if (context?.isScreenOn() == true) { - context!!.showTimerNotification(false) - Handler().postDelayed({ - context?.hideTimerNotification() - }, context?.config!!.timerMaxReminderSecs * 1000L) - } else { - Intent(context, ReminderActivity::class.java).apply { - activity?.startActivity(this) - } - } - } else if (diff > 0 && !isForegrounded && isRunning) { - showNotification(formattedDuration) - } - - return true - } - - @TargetApi(Build.VERSION_CODES.O) - private fun showNotification(formattedDuration: String) { - val channelId = "simple_alarm_timer" - val label = getString(R.string.timer) - val notificationManager = context!!.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - if (isOreoPlus()) { - val importance = NotificationManager.IMPORTANCE_HIGH - NotificationChannel(channelId, label, importance).apply { - setSound(null, null) - notificationManager.createNotificationChannel(this) - } - } - - val builder = NotificationCompat.Builder(context) - .setContentTitle(label) - .setContentText(formattedDuration) - .setSmallIcon(R.drawable.ic_timer) - .setContentIntent(context!!.getOpenTimerTabIntent()) - .setPriority(Notification.PRIORITY_HIGH) - .setSound(null) - .setOngoing(true) - .setAutoCancel(true) - .setChannelId(channelId) - - builder.setVisibility(Notification.VISIBILITY_PUBLIC) - notificationManager.notify(TIMER_NOTIF_ID, builder.build()) - } - - private val updateRunnable = object : Runnable { - override fun run() { - if (isRunning) { - if (updateDisplayedText()) { - currentTicks++ - totalTicks++ - updateHandler.postAtTime(this, uptimeAtStart + currentTicks * UPDATE_INTERVAL) - } - } - } + fun updateAlarmSound(alarmSound: AlarmSound) { + requiredActivity.config.timerSoundTitle = alarmSound.title + requiredActivity.config.timerSoundUri = alarmSound.uri + view.timer_sound.text = alarmSound.title } } diff --git a/app/src/main/kotlin/com/simplemobiletools/clock/helpers/Config.kt b/app/src/main/kotlin/com/simplemobiletools/clock/helpers/Config.kt index e0ebdcda..104b8ba0 100644 --- a/app/src/main/kotlin/com/simplemobiletools/clock/helpers/Config.kt +++ b/app/src/main/kotlin/com/simplemobiletools/clock/helpers/Config.kt @@ -1,6 +1,9 @@ package com.simplemobiletools.clock.helpers import android.content.Context +import com.simplemobiletools.clock.extensions.gson.gson +import com.simplemobiletools.clock.services.StateWrapper +import com.simplemobiletools.clock.services.TimerState import com.simplemobiletools.commons.extensions.getDefaultAlarmTitle import com.simplemobiletools.commons.extensions.getDefaultAlarmUri import com.simplemobiletools.commons.helpers.ALARM_SOUND_TYPE_ALARM @@ -27,6 +30,12 @@ class Config(context: Context) : BaseConfig(context) { get() = prefs.getInt(TIMER_SECONDS, 300) set(lastTimerSeconds) = prefs.edit().putInt(TIMER_SECONDS, lastTimerSeconds).apply() + var timerState: TimerState + get() = prefs.getString(TIMER_STATE, null)?.let { state -> + gson.fromJson(state, StateWrapper::class.java) + }?.state ?: TimerState.Idle + set(state) = prefs.edit().putString(TIMER_STATE, gson.toJson(StateWrapper(state))).apply() + var timerVibrate: Boolean get() = prefs.getBoolean(TIMER_VIBRATE, false) set(timerVibrate) = prefs.edit().putBoolean(TIMER_VIBRATE, timerVibrate).apply() diff --git a/app/src/main/kotlin/com/simplemobiletools/clock/helpers/Constants.kt b/app/src/main/kotlin/com/simplemobiletools/clock/helpers/Constants.kt index 4f6ce6b8..c58f2a5d 100644 --- a/app/src/main/kotlin/com/simplemobiletools/clock/helpers/Constants.kt +++ b/app/src/main/kotlin/com/simplemobiletools/clock/helpers/Constants.kt @@ -8,6 +8,8 @@ const val SHOW_SECONDS = "show_seconds" const val SELECTED_TIME_ZONES = "selected_time_zones" const val EDITED_TIME_ZONE_TITLES = "edited_time_zone_titles" const val TIMER_SECONDS = "timer_seconds" +const val TIMER_START_TIMESTAMP = "timer_timetamp" +const val TIMER_STATE = "timer_state" const val TIMER_VIBRATE = "timer_vibrate" const val TIMER_SOUND_URI = "timer_sound_uri" const val TIMER_SOUND_TITLE = "timer_sound_title" @@ -30,6 +32,7 @@ const val UPDATE_WIDGET_INTENT_ID = 9997 const val OPEN_APP_INTENT_ID = 9998 const val ALARM_NOTIF_ID = 9998 const val TIMER_NOTIF_ID = 9999 +const val TIMER_RUNNING_NOTIF_ID = 10000 const val OPEN_TAB = "open_tab" const val TAB_CLOCK = 0 diff --git a/app/src/main/kotlin/com/simplemobiletools/clock/receivers/HideTimerReceiver.kt b/app/src/main/kotlin/com/simplemobiletools/clock/receivers/HideTimerReceiver.kt index e12a9a21..5d3a739a 100644 --- a/app/src/main/kotlin/com/simplemobiletools/clock/receivers/HideTimerReceiver.kt +++ b/app/src/main/kotlin/com/simplemobiletools/clock/receivers/HideTimerReceiver.kt @@ -4,9 +4,12 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import com.simplemobiletools.clock.extensions.hideTimerNotification +import com.simplemobiletools.clock.services.TimerState +import org.greenrobot.eventbus.EventBus class HideTimerReceiver : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { context.hideTimerNotification() + EventBus.getDefault().post(TimerState.Idle) } } diff --git a/app/src/main/kotlin/com/simplemobiletools/clock/services/TimerService.kt b/app/src/main/kotlin/com/simplemobiletools/clock/services/TimerService.kt new file mode 100644 index 00000000..04cefac6 --- /dev/null +++ b/app/src/main/kotlin/com/simplemobiletools/clock/services/TimerService.kt @@ -0,0 +1,114 @@ +package com.simplemobiletools.clock.services + +import android.annotation.TargetApi +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.Service +import android.content.Context +import android.content.Intent +import android.os.Build +import android.os.IBinder +import androidx.annotation.RequiresApi +import androidx.core.app.NotificationCompat +import com.simplemobiletools.clock.R +import com.simplemobiletools.clock.extensions.config +import com.simplemobiletools.clock.extensions.getOpenTimerTabIntent +import com.simplemobiletools.clock.helpers.TIMER_RUNNING_NOTIF_ID +import com.simplemobiletools.commons.extensions.getFormattedDuration +import com.simplemobiletools.commons.helpers.isOreoPlus +import org.greenrobot.eventbus.EventBus +import org.greenrobot.eventbus.Subscribe +import org.greenrobot.eventbus.ThreadMode + +@RequiresApi(Build.VERSION_CODES.O) +fun startTimerService(context: Context) { + if (isOreoPlus()) { + context.startForegroundService(Intent(context, TimerService::class.java)) + } else { + context.startService(Intent(context, TimerService::class.java)) + } +} + +class TimerService : Service() { + + private val bus = EventBus.getDefault() + + override fun onCreate() { + super.onCreate() + bus.register(this) + } + + override fun onBind(intent: Intent?): IBinder? = null + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + super.onStartCommand(intent, flags, startId) + + val formattedDuration = config.timerSeconds.getFormattedDuration() + startForeground(TIMER_RUNNING_NOTIF_ID, notification(formattedDuration)) + + return START_NOT_STICKY + } + + @Subscribe(threadMode = ThreadMode.MAIN) + fun onMessageEvent(event: TimerStopService) { + stopService() + } + + private fun stopService() { + if (isOreoPlus()) { + stopForeground(true) + } else { + stopSelf() + } + } + + override fun onDestroy() { + super.onDestroy() + bus.unregister(this) + } + + @TargetApi(Build.VERSION_CODES.O) + private fun notification(formattedDuration: String): Notification { + val channelId = "simple_alarm_timer" + val label = getString(R.string.timer) + val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + if (isOreoPlus()) { + val importance = NotificationManager.IMPORTANCE_DEFAULT + NotificationChannel(channelId, label, importance).apply { + setSound(null, null) + notificationManager.createNotificationChannel(this) + } + } + + val builder = NotificationCompat.Builder(this) + .setContentTitle(label) + .setContentText(formattedDuration) + .setSmallIcon(R.drawable.ic_timer) + .setContentIntent(this.getOpenTimerTabIntent()) + .setPriority(Notification.PRIORITY_DEFAULT) + .setSound(null) + .setOngoing(true) + .setAutoCancel(true) + .setChannelId(channelId) + + builder.setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + return builder.build() + } +} + +data class StateWrapper(val state: TimerState) + +object TimerStopService + +sealed class TimerState { + object Idle : TimerState() + data class Start(val duration: Long) : TimerState() + data class Running(val duration: Long, val tick: Long) : TimerState() + data class Pause(val duration: Long) : TimerState() + data class Paused(val duration: Long, val tick: Long) : TimerState() + data class Finish(val duration: Long) : TimerState() + object Finished : TimerState() +} + +