Fix #77
This commit is contained in:
Tibor Kaputa 2020-03-10 17:21:31 +01:00 committed by GitHub
commit 9f6271eb2f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 696 additions and 243 deletions

View file

@ -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'
}

View file

@ -79,6 +79,7 @@
</activity>
<service android:name=".services.SnoozeService"/>
<service android:name=".services.TimerService" />
<receiver android:name=".receivers.AlarmReceiver"/>

View file

@ -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()
}
}

View file

@ -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

View file

@ -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)

View file

@ -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())

View file

@ -0,0 +1,7 @@
package com.simplemobiletools.clock.extensions
import android.util.Log
import com.simplemobiletools.clock.BuildConfig
fun <A> A.log(tag: String) = apply { if (BuildConfig.DEBUG) Log.wtf(tag, this.toString()) }
fun <A> A.log(first: String, tag: String) = apply { if (BuildConfig.DEBUG) Log.wtf(tag, first) }

View file

@ -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)

View file

@ -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:
* <pre> {@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;
* }
* }</pre>
* <p>Without additional type information, the serialized JSON is ambiguous. Is
* the bottom shape in this drawing a rectangle or a diamond? <pre> {@code
* {
* "bottomShape": {
* "width": 10,
* "height": 5,
* "x": 0,
* "y": 0
* },
* "topShape": {
* "radius": 2,
* "x": 4,
* "y": 1
* }
* }}</pre>
* This class addresses this problem by adding type information to the
* serialized JSON and honoring that type information when the JSON is
* deserialized: <pre> {@code
* {
* "bottomShape": {
* "type": "Diamond",
* "width": 10,
* "height": 5,
* "x": 0,
* "y": 0
* },
* "topShape": {
* "type": "Circle",
* "radius": 2,
* "x": 4,
* "y": 1
* }
* }}</pre>
* Both the type field name ({@code "type"}) and the type labels ({@code
* "Rectangle"}) are configurable.
*
* <h3>Registering Types</h3>
* 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. <pre> {@code
* RuntimeTypeAdapterFactory<Shape> shapeAdapterFactory
* = RuntimeTypeAdapterFactory.of(Shape.class, "type");
* }</pre>
* 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.
* <pre> {@code
* shapeAdapterFactory.registerSubtype(Rectangle.class, "Rectangle");
* shapeAdapterFactory.registerSubtype(Circle.class, "Circle");
* shapeAdapterFactory.registerSubtype(Diamond.class, "Diamond");
* }</pre>
* Finally, register the type adapter factory in your application's GSON builder:
* <pre> {@code
* Gson gson = new GsonBuilder()
* .registerTypeAdapterFactory(shapeAdapterFactory)
* .create();
* }</pre>
* Like {@code GsonBuilder}, this API supports chaining: <pre> {@code
* RuntimeTypeAdapterFactory<Shape> shapeAdapterFactory = RuntimeTypeAdapterFactory.of(Shape.class)
* .registerSubtype(Rectangle.class)
* .registerSubtype(Circle.class)
* .registerSubtype(Diamond.class);
* }</pre>
*/
public final class RuntimeTypeAdapterFactory<T> implements TypeAdapterFactory {
private final Class<?> baseType;
private final String typeFieldName;
private final Map<String, Class<?>> labelToSubtype = new LinkedHashMap<String, Class<?>>();
private final Map<Class<?>, String> subtypeToLabel = new LinkedHashMap<Class<?>, 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 <T> RuntimeTypeAdapterFactory<T> of(Class<T> baseType, String typeFieldName, boolean maintainType) {
return new RuntimeTypeAdapterFactory<T>(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 <T> RuntimeTypeAdapterFactory<T> of(Class<T> baseType, String typeFieldName) {
return new RuntimeTypeAdapterFactory<T>(baseType, typeFieldName, false);
}
/**
* Creates a new runtime type adapter for {@code baseType} using {@code "type"} as
* the type field name.
*/
public static <T> RuntimeTypeAdapterFactory<T> of(Class<T> baseType) {
return new RuntimeTypeAdapterFactory<T>(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<T> registerSubtype(Class<? extends T> 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<T> registerSubtype(Class<? extends T> type) {
return registerSubtype(type, type.getSimpleName());
}
public <R> TypeAdapter<R> create(Gson gson, TypeToken<R> type) {
if (type.getRawType() != baseType) {
return null;
}
final Map<String, TypeAdapter<?>> labelToDelegate
= new LinkedHashMap<String, TypeAdapter<?>>();
final Map<Class<?>, TypeAdapter<?>> subtypeToDelegate
= new LinkedHashMap<Class<?>, TypeAdapter<?>>();
for (Map.Entry<String, Class<?>> 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<R>() {
@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<R> delegate = (TypeAdapter<R>) 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<R> delegate = (TypeAdapter<R>) 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<String, JsonElement> e : jsonObject.entrySet()) {
clone.add(e.getKey(), e.getValue());
}
Streams.write(clone, out);
}
}.nullSafe();
}
}

View file

@ -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<TimerState>()
.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 <reified T : Any> valueOf(): RuntimeTypeAdapterFactory<T> = RuntimeTypeAdapterFactory.of(T::class.java)
fun GsonBuilder.registerTypes(vararg types: TypeAdapterFactory) = apply {
types.forEach { registerTypeAdapterFactory(it) }
}
val gson: Gson = GsonBuilder().registerTypes(timerStates).create()

View file

@ -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
}
}

View file

@ -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()

View file

@ -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

View file

@ -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)
}
}

View file

@ -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()
}