diff --git a/app/build.gradle b/app/build.gradle index dc51efa..c33a24d 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -75,4 +75,5 @@ dependencies { 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' } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 2f44e93..00fb347 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/extensions/Context.kt b/app/src/main/kotlin/com/simplemobiletools/clock/extensions/Context.kt index 2277ef3..009b2e1 100644 --- a/app/src/main/kotlin/com/simplemobiletools/clock/extensions/Context.kt +++ b/app/src/main/kotlin/com/simplemobiletools/clock/extensions/Context.kt @@ -226,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/Int.kt b/app/src/main/kotlin/com/simplemobiletools/clock/extensions/Int.kt new file mode 100644 index 0000000..56c7f19 --- /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/Long.kt b/app/src/main/kotlin/com/simplemobiletools/clock/extensions/Long.kt index e4eac62..7500e53 100644 --- a/app/src/main/kotlin/com/simplemobiletools/clock/extensions/Long.kt +++ b/app/src/main/kotlin/com/simplemobiletools/clock/extensions/Long.kt @@ -36,3 +36,6 @@ fun Long.timestampFormat(format: String = "dd. MM. yyyy"): String { 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 0000000..b04dba4 --- /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 0000000..7d9b5af --- /dev/null +++ b/app/src/main/kotlin/com/simplemobiletools/clock/extensions/gson/typeAdapter.kt @@ -0,0 +1,22 @@ +package app.common + +import com.google.gson.GsonBuilder +import com.google.gson.TypeAdapterFactory +import com.simplemobiletools.clock.extensions.gson.RuntimeTypeAdapterFactory +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) + +inline fun valueOf(): RuntimeTypeAdapterFactory = RuntimeTypeAdapterFactory.of(T::class.java) + +fun GsonBuilder.registerTypes(vararg types: TypeAdapterFactory) = apply { + types.forEach { registerTypeAdapterFactory(it) } +} + +val 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 b8fe52c..d87dda1 100644 --- a/app/src/main/kotlin/com/simplemobiletools/clock/fragments/TimerFragment.kt +++ b/app/src/main/kotlin/com/simplemobiletools/clock/fragments/TimerFragment.kt @@ -1,51 +1,58 @@ 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.graphics.Color import android.media.AudioManager -import android.os.Build import android.os.Bundle -import android.os.CountDownTimer import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import androidx.core.app.NotificationCompat import androidx.fragment.app.Fragment -import androidx.lifecycle.Observer -import androidx.work.WorkInfo -import androidx.work.WorkManager import com.simplemobiletools.clock.R import com.simplemobiletools.clock.activities.SimpleActivity import com.simplemobiletools.clock.dialogs.MyTimePickerDialogDialog import com.simplemobiletools.clock.extensions.* -import com.simplemobiletools.clock.helpers.Config import com.simplemobiletools.clock.helpers.PICK_AUDIO_FILE_INTENT_ID -import com.simplemobiletools.clock.helpers.TIMER_NOTIF_ID -import com.simplemobiletools.clock.workers.* +import com.simplemobiletools.clock.services.TimerState +import com.simplemobiletools.clock.services.startTimerService 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 java.util.concurrent.TimeUnit - +import org.greenrobot.eventbus.EventBus +import org.greenrobot.eventbus.Subscribe +import org.greenrobot.eventbus.ThreadMode class TimerFragment : Fragment() { lateinit var view: ViewGroup - private var timer: CountDownTimer? = null - private var isRunning = false + private var timerState: TimerState = TimerState.Idle + + override fun onResume() { + super.onResume() + + timerState = requiredActivity.config.timerState + updateViewStates(timerState) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + EventBus.getDefault().register(this) + } + + override fun onDestroy() { + EventBus.getDefault().unregister(this) + super.onDestroy() + } 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 + timer_time.text = 0.getFormattedDuration() + requiredActivity.updateTextColors(timer_fragment) timer_play_pause.background = resources.getColoredDrawableWithColor(R.drawable.circle_background_filled, context!!.getAdjustedPrimaryColor()) timer_reset.applyColorFilter(textColor) @@ -60,27 +67,18 @@ class TimerFragment : Fragment() { timer_sound.colorLeftDrawable(textColor) timer_time.setOnClickListener { - if (isRunning) { - pauseTimer(config) - } else { - startTimer(config) - } + EventBus.getDefault().post(TimerState.Idle) + requiredActivity.hideTimerNotification() + requiredActivity.toast(R.string.timer_stopped) } timer_play_pause.setOnClickListener { - if (isRunning) { - pauseTimer(config) - } else { - startTimer(config) - } + context.startTimerService() } timer_reset.setOnClickListener { - cancelTimerWorker() + EventBus.getDefault().post(TimerState.Idle) requiredActivity.hideTimerNotification() - - config.timerTickStamp = 0L - config.timerStartStamp = 0L requiredActivity.toast(R.string.timer_stopped) } @@ -114,72 +112,46 @@ class TimerFragment : Fragment() { context.checkAlarmsWithDeletedSoundUri(sound.uri) }) } - - WorkManager.getInstance(requiredActivity).getWorkInfosByTagLiveData(TIMER_WORKER_KEY).observe(requiredActivity, Observer { workInfo -> - val workerState = workInfo?.firstOrNull()?.state - isRunning = (workerState == WorkInfo.State.ENQUEUED) - - updateIcons(isRunning) - timer_reset.beVisibleIf(isRunning) - timer?.cancel() - - when (workerState) { - WorkInfo.State.ENQUEUED -> { - val duration = config.timerSeconds.toLong() * 1000 //MS - - timer = object : CountDownTimer(duration, 1000) { - override fun onTick(millisUntilFinished: Long) { - timer_time.text = TimeUnit.MILLISECONDS.toSeconds(millisUntilFinished).toInt().getFormattedDuration() - } - - override fun onFinish() {} - }.start() - } - - else -> { - timer_time.text = 0.getFormattedDuration() - } - } - }) - - cancelTimerWorker() } return view } - private fun startTimer(config: Config) { - val isTimerNoTick = config.timerTickStamp == 0L - - if (isTimerNoTick) { - config.timerStartStamp = System.currentTimeMillis() - - val selectedDuration = config.timerSeconds - val formattedTimestamp = config.timerStartStamp.timestampFormat("HH:mm:ss") - - enqueueTimerWorker(TimeUnit.SECONDS.toMillis(selectedDuration.toLong())) - showNotification("(${selectedDuration.getFormattedDuration()}) $formattedTimestamp") - } else { - val duration = config.timerSeconds.toLong() * 1000 //MS - val selectedDuration = (config.timerStartStamp + duration) - (config.timerTickStamp - config.timerStartStamp) - val formattedTimestamp = config.timerStartStamp.timestampFormat("HH:mm:ss") - - enqueueTimerWorker(TimeUnit.SECONDS.toMillis(selectedDuration.toLong())) - showNotification("(${selectedDuration.toInt().getFormattedDuration()}) $formattedTimestamp") - } + @Subscribe(threadMode = ThreadMode.MAIN) + fun onMessageEvent(state: TimerState.Idle) { + view.timer_time.text = 0.getFormattedDuration() + updateViewStates(state) } - private fun pauseTimer(config: Config) { - cancelTimerWorker() - requiredActivity.hideTimerNotification() + @Subscribe(threadMode = ThreadMode.MAIN) + fun onMessageEvent(state: TimerState.Running) { + view.timer_time.text = state.tick.div(1000).toInt().getFormattedDuration() + updateViewStates(state) + } - config.timerTickStamp = System.currentTimeMillis() + @Subscribe(threadMode = ThreadMode.MAIN) + fun onMessageEvent(state: TimerState.Paused) { + updateViewStates(state) + } - val tick = config.timerTickStamp - val duration = config.timerSeconds.toLong() * 1000 //MS - val startedAt = config.timerStartStamp - val distance = duration - (tick - startedAt) - view.timer_time.text = distance.toInt().getFormattedDuration() + private fun updateViewStates(timerState: TimerState) { + view.timer_reset.beVisibleIf(timerState is TimerState.Running || timerState is TimerState.Paused) + + val drawableId = + if (timerState 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)) } fun updateAlarmSound(alarmSound: AlarmSound) { @@ -187,38 +159,4 @@ class TimerFragment : Fragment() { requiredActivity.config.timerSoundUri = alarmSound.uri view.timer_sound.text = alarmSound.title } - - private fun updateIcons(isRunning: Boolean) { - val drawableId = if (isRunning) 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)) - } - - @TargetApi(Build.VERSION_CODES.O) - private fun showNotification(formattedDuration: String) { - val channelId = "simple_alarm_timer" - val label = getString(R.string.timer) - val notificationManager = requiredActivity.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(NotificationCompat.VISIBILITY_PUBLIC) - notificationManager.notify(TIMER_NOTIF_ID, builder.build()) - } } 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 9f2f8ab..6590627 100644 --- a/app/src/main/kotlin/com/simplemobiletools/clock/helpers/Config.kt +++ b/app/src/main/kotlin/com/simplemobiletools/clock/helpers/Config.kt @@ -1,10 +1,15 @@ package com.simplemobiletools.clock.helpers import android.content.Context +import app.common.gson +import com.google.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 import com.simplemobiletools.commons.helpers.BaseConfig +import java.sql.Time class Config(context: Context) : BaseConfig(context) { companion object { @@ -27,13 +32,11 @@ class Config(context: Context) : BaseConfig(context) { get() = prefs.getInt(TIMER_SECONDS, 300) set(lastTimerSeconds) = prefs.edit().putInt(TIMER_SECONDS, lastTimerSeconds).apply() - var timerStartStamp: Long - get() = prefs.getLong(TIMER_START_TIMESTAMP, 0L) - set(timestamp) = prefs.edit().putLong(TIMER_START_TIMESTAMP, timestamp).apply() - - var timerTickStamp: Long - get() = prefs.getLong(TIMER_TICK_TIMESTAMP, 0L) - set(timestamp) = prefs.edit().putLong(TIMER_TICK_TIMESTAMP, timestamp).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) 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 f383d54..c58f2a5 100644 --- a/app/src/main/kotlin/com/simplemobiletools/clock/helpers/Constants.kt +++ b/app/src/main/kotlin/com/simplemobiletools/clock/helpers/Constants.kt @@ -9,7 +9,7 @@ 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_TICK_TIMESTAMP = "timer_tick" +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" @@ -32,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/services/timerService.kt b/app/src/main/kotlin/com/simplemobiletools/clock/services/timerService.kt new file mode 100644 index 0000000..1cac5e4 --- /dev/null +++ b/app/src/main/kotlin/com/simplemobiletools/clock/services/timerService.kt @@ -0,0 +1,156 @@ +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.CountDownTimer +import android.os.IBinder +import androidx.core.app.NotificationCompat +import com.simplemobiletools.clock.R +import com.simplemobiletools.clock.extensions.* +import com.simplemobiletools.clock.helpers.TIMER_NOTIF_ID +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 + +fun Context.startTimerService() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + startForegroundService(Intent(this, TimerService::class.java)) + } else { + startService(Intent(this, TimerService::class.java)) + } +} + +class TimerService : Service() { + + private var timer: CountDownTimer? = null + private var lastTick = 0L + 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)) + + when (val state = config.timerState) { + is TimerState.Idle -> bus.post(TimerState.Start(config.timerSeconds.secondsToMillis)) + is TimerState.Paused -> bus.post(TimerState.Start(state.tick)) + is TimerState.Running -> bus.post(TimerState.Pause(state.tick)) + else -> {} + } + + return START_NOT_STICKY + } + + @Subscribe(threadMode = ThreadMode.MAIN) + fun onMessageEvent(state: TimerState.Idle) { + config.timerState = state + timer?.cancel() + stopService() + } + + @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) + bus.post(newState) + config.timerState = newState + } + + override fun onFinish() { + bus.post(TimerState.Finish(state.duration)) + } + }.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) + + bus.post(TimerState.Idle) + } + + @Subscribe(threadMode = ThreadMode.MAIN) + fun onMessageEvent(event: TimerState.Pause) { + bus.post(TimerState.Paused(event.duration, lastTick)) + } + + @Subscribe(threadMode = ThreadMode.MAIN) + fun onMessageEvent(state: TimerState.Paused) { + config.timerState = state + timer?.cancel() + stopService() + } + + private fun stopService() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) 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_HIGH + 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_HIGH) + .setSound(null) + .setOngoing(true) + .setAutoCancel(true) + .setChannelId(channelId) + + builder.setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + return builder.build() + } +} + +data class StateWrapper(val state: TimerState) + +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() +} diff --git a/app/src/main/kotlin/com/simplemobiletools/clock/workers/timerWorker.kt b/app/src/main/kotlin/com/simplemobiletools/clock/workers/timerWorker.kt deleted file mode 100644 index 1bb655c..0000000 --- a/app/src/main/kotlin/com/simplemobiletools/clock/workers/timerWorker.kt +++ /dev/null @@ -1,41 +0,0 @@ -package com.simplemobiletools.clock.workers - -import android.content.Context -import androidx.fragment.app.Fragment -import androidx.work.* -import com.simplemobiletools.clock.extensions.* -import java.util.* -import java.util.concurrent.TimeUnit - -private const val TIMER_REQUEST_ID = "TIMER_REQUEST_ID" -const val TIMER_WORKER_KEY = "TIMER_WORKER_KEY" - -private fun Fragment.saveTimerRequestId(uuid: UUID) = - preferences.edit().putString(TIMER_REQUEST_ID, uuid.toString()).apply() - -val Fragment.timerRequestId: UUID? - get() = - preferences.getString(TIMER_REQUEST_ID, UUID.randomUUID().toString())?.let { UUID.fromString(it) } - -fun Fragment.cancelTimerWorker() = - WorkManager.getInstance(requiredActivity).cancelAllWorkByTag(TIMER_WORKER_KEY) - -fun Fragment.enqueueTimerWorker(delay: Long) = - WorkManager.getInstance(requiredActivity).enqueueUniqueWork(TIMER_WORKER_KEY, ExistingWorkPolicy.REPLACE, timerRequest(delay)) - -private fun Fragment.timerRequest(delay: Long): OneTimeWorkRequest = - OneTimeWorkRequestBuilder().setInitialDelay(delay, TimeUnit.MILLISECONDS).addTag(TIMER_WORKER_KEY).build().also { - saveTimerRequestId(it.id) -} - -class TimerWorker(val context: Context, workerParams: WorkerParameters) : Worker(context, workerParams) { - override fun doWork(): Result = - try { - context.showTimerNotification(false) - context.config.timerTickStamp = 0L - context.config.timerStartStamp = 0L - Result.success() - } catch (exception: Exception) { - Result.failure() - } -}