0

I want the ability to set a reusable timer (e.g. 20 seconds) and then have it start counting down, but I could also minimize the app, do something else, and have the timer still notify me. The timer should also be start / stop / pause / reset-able.

I've seen AlarmManager but I read that it seems to be broken on several devices. Is there a more robust solution?

edit: trying a Service

The Manifest:

<?xml version="1.0" encoding="utf-8"?>
<manifest package="com.packagename.timertest"
          xmlns:android="http://schemas.android.com/apk/res/android">

    <application
        android:allowBackup="true"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@style/AppTheme">
        <activity android:name=".MainActivity">
            <intent-filter>
                <action android:name="android.intent.action.MAIN"/>

                <category android:name="android.intent.category.LAUNCHER"/>
            </intent-filter>
        </activity>

        <service android:name=".TimerService"
                 android:exported="false"/>
    </application>

</manifest>

MainActivity for launching the fragment:

public class MainActivity extends AppCompatActivity {
    private Button launchTimerPanelButton;
    private FragmentManager fragmentManager;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        fragmentManager = getSupportFragmentManager();

        launchTimerPanelButton = (Button) findViewById(R.id.launch_timer_button);
        launchTimerPanelButton.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                TimerDialogFragment dialogFragment = TimerDialogFragment.newInstance(10);
                dialogFragment.setStyle(DialogFragment.STYLE_NORMAL, R.style.CustomDialog);
                dialogFragment.show(fragmentManager,"");
            }
        });
    }
}

The XML for MainActivity:

<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.ConstraintLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context="com.packagename.timertest.MainActivity">

    <Button
        android:id="@+id/launch_timer_button"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="Launch Timer Panel"/>

</android.support.constraint.ConstraintLayout>

The DialogFragment:

public class TimerDialogFragment extends DialogFragment {
    private static final String ARGUMENT_NUM_SECONDS = "state_num_seconds";
    private static final String STATE_NUM_SECONDS = "state_num_seconds";
    public static final String STATE_IS_BROADCAST_RECEIVER_REGISTERED = "state_is_broadcast_receiver_registered";
    private int numSecondsInitial;
    private int numSeconds;
    private TextView secondsRemainingTextView;
    private Button startButton;
    private Button pauseButton;
    private Button resetButton;
    private Button closeButton;
    private BroadcastReceiver restTimerReceiver;
    private boolean isBroadcastReceiverRegistered;

    public static TimerDialogFragment newInstance(int numSeconds) {
        Bundle args = new Bundle();
        TimerDialogFragment fragment = new TimerDialogFragment();
        args.putInt(ARGUMENT_NUM_SECONDS, numSeconds);
        fragment.setArguments(args);
        return fragment;
    }

    @Override
    public void onCreate(@Nullable Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        numSecondsInitial = getArguments().getInt(ARGUMENT_NUM_SECONDS);
        if (savedInstanceState == null) {
            numSeconds = numSecondsInitial;
        }
        else {
            numSeconds = savedInstanceState.getInt(STATE_NUM_SECONDS);
        }

        isBroadcastReceiverRegistered = false;
        if (savedInstanceState != null) {
            isBroadcastReceiverRegistered = savedInstanceState.getBoolean(STATE_IS_BROADCAST_RECEIVER_REGISTERED);
        }

        restTimerReceiver = new BroadcastReceiver() {
            @Override
            public void onReceive(Context context, Intent intent) {
                //???????
            }
        };
        registerBroadcastReceiver();
    }

    @Nullable
    @Override
    public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
        View contentView = inflater.inflate(R.layout.dialogfragment_timer, container, false);
        getDialog().setTitle("Timer");

        secondsRemainingTextView = (TextView) contentView.findViewById(R.id.seconds_remaining_textview);
        secondsRemainingTextView.setText(numSeconds + "");

        startButton = (Button) contentView.findViewById(R.id.start_button);
        pauseButton = (Button) contentView.findViewById(R.id.pause_button);
        resetButton = (Button) contentView.findViewById(R.id.reset_button);
        closeButton = (Button) contentView.findViewById(R.id.close_button);


        startButton.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {

            }
        });

        pauseButton.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {

            }
        });

        resetButton.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {

            }
        });

        closeButton.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                //end the timer first?
                unregisterBroadcastReceiver();
                dismiss();
            }
        });

        return contentView;
    }

    @Override
    public void onSaveInstanceState(Bundle outState) {
        outState.putInt(STATE_NUM_SECONDS, numSeconds);
        super.onSaveInstanceState(outState);
    }

    @Override
    public void onResume() {
        registerBroadcastReceiver();
        super.onResume();
    }

    @Override
    public void onPause() {
        unregisterBroadcastReceiver();
        super.onPause();
    }

    @Override
    public void onDestroy() {
        unregisterBroadcastReceiver();
        super.onDestroy();
    }


    private void registerBroadcastReceiver() {
        if (!isBroadcastReceiverRegistered) {
            LocalBroadcastManager.getInstance(getActivity()).registerReceiver(restTimerReceiver, new IntentFilter(TimerService.TIMER_SERVICE));
            isBroadcastReceiverRegistered = true;
        }
    }

    private void unregisterBroadcastReceiver() {
        if (isBroadcastReceiverRegistered) {
            isBroadcastReceiverRegistered = false;
            LocalBroadcastManager.getInstance(getActivity()).unregisterReceiver(restTimerReceiver);
        }
    }

}

its layout:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
              android:orientation="vertical"
              android:layout_width="match_parent"
              android:layout_height="wrap_content"
              android:padding="10dp"
    android:gravity="center">


    <TextView
        android:id="@+id/seconds_remaining_textview"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center"
        android:textAppearance="?android:attr/textAppearanceLarge"
        android:gravity="center"
        android:text="10"/>

    <Button
        android:id="@+id/start_button"
        android:layout_marginTop="20dp"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_gravity="center"
        android:textAppearance="?android:attr/textAppearanceMedium"
        android:gravity="center"
        android:text="Start Timer"/>


    <Button
        android:id="@+id/pause_button"
        android:layout_marginTop="20dp"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_gravity="center"
        android:textAppearance="?android:attr/textAppearanceMedium"
        android:gravity="center"
        android:text="Pause Timer"/>


    <Button
        android:id="@+id/reset_button"
        android:layout_marginTop="20dp"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_gravity="center"
        android:textAppearance="?android:attr/textAppearanceMedium"
        android:gravity="center"
        android:text="Reset Timer"/>

    <Button
        android:id="@+id/close_button"
        android:layout_marginTop="20dp"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_gravity="center"
        android:textAppearance="?android:attr/textAppearanceMedium"
        android:gravity="center"
        android:text="Close / End Timer"/>

</LinearLayout>

The service:

public class TimerService extends Service {
    private String LOG_TAG = TimerService.class.getSimpleName();
    public static final String TIMER_SERVICE = "timer_service";

    @Override
    public void onCreate() {
        super.onCreate();
        Log.i(LOG_TAG, "OnCreate");
    }

    @Override
    public int onStartCommand(Intent intent, int flags, int startId) {
        Log.i(LOG_TAG, "In onStartCommand");
        new Thread(new Runnable() {
            public void run() {
                //something
            }
        }).start();
        return START_REDELIVER_INTENT;
    }

    @Nullable
    @Override
    public IBinder onBind(Intent intent) {
        Log.i(LOG_TAG, "OnBind");
        return null;
    }

    @TargetApi(Build.VERSION_CODES.ICE_CREAM_SANDWICH)
    @Override
    public void onTaskRemoved(Intent rootIntent) {
        super.onTaskRemoved(rootIntent);
        Log.i(LOG_TAG, "In onTaskRemoved");
    }

    @Override
    public void onDestroy() {
        super.onDestroy();
        Log.i(LOG_TAG, "In onDestroy");
    }
}

2 Answers2

0

If this is a one-time process, I think a Service would be the best option. You can do whatever you want there and, when the process is finished, send a Broadcast to notify it has finished.

allo86
  • 946
  • 1
  • 9
  • 23
  • It's a timer you could start/stop/pause/reset, not a one-time process – user8364053 Jul 25 '17 at 16:35
  • I have used Service to play audio broadcasts using Service :) and it fits in what you need (start, stop, pause, reset). Check this example https://developer.android.com/guide/topics/media/mediaplayer.html#mpandservices. What I did was to show a notification with some buttons that allowed to pause and stop the audio. Each button delivered a different command to the Service. You can do the same, or you can also show a simple notification and open your app when it is touched. – allo86 Jul 25 '17 at 16:40
  • How do you show a notification with start/stop? And is this different from actually sending some kind of alarm? I've used IntentServices before but don't know the right way to implement time into them. – user8364053 Jul 25 '17 at 16:43
  • IntentService is a simplified version of Service. Since it auto manages its queue, may be not enough for what you want. That is why I suggested a Service. About showing buttons in the notification, you can check the Android documentation (https://developer.android.com/guide/topics/ui/notifiers/notifications.html#Actions) and also some other questions in stackoverflow, like this one https://stackoverflow.com/questions/34377899/how-to-add-buttons-to-a-push-notification-in-android – allo86 Jul 25 '17 at 16:47
  • Not familiar with using Service by itself, seems to require a single `public IBinder onBind(Intent intent)` and called with `Intent intent = new Intent(this, HelloService.class); startService(intent);`, do I still somehow use this with broadcast receiver? Register/unregister in the start/pause/resume/destroy methods? – user8364053 Jul 25 '17 at 17:00
  • You can use it with startService (https://developer.android.com/reference/android/content/Context.html#startService(android.content.Intent)) and deliver the command you want to perform actions in the Service. The broadcast receiver could be in your Activity if it is open, or you could launch a new instance of it from the notification – allo86 Jul 25 '17 at 17:03
  • I can't manage the receiver or start/stop the service from a Fragment? – user8364053 Jul 25 '17 at 17:15
  • Yes, you can do it from a Fragment – allo86 Jul 25 '17 at 19:32
  • Doesn't seem to allow it from what I am trying – user8364053 Jul 25 '17 at 19:39
  • I do not see any call to getActivity().startService(). How are you starting the service? – allo86 Jul 25 '17 at 20:15
  • I'm not entirely sure where to put it – user8364053 Jul 25 '17 at 20:32
  • From your code, I would put them here `startButton.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { // TODO: Start service here. Specify an action, like "START" } });` Then, in your `Service`: `public void onStartCommand() { if(intent.getAction.equals("START") { // start your timer } else if(intent.getAction.equals("STOP") { // stop your timer } }` I hope it is clearer now. – allo86 Jul 25 '17 at 20:39
  • But then if I push the button several times it starts a new service each time? How do I handle the timer inside the service? Handler postdelay? Something else? And it still doesn't let me handle these events in the Fragment, I think it's forcing it to go through the Activity. – user8364053 Jul 25 '17 at 20:45
  • That depends on your logic. You can send broadcasts from your Service to your Activity to inform that the timer has started, another broadcast every second to update your timer, a broadcast to confirm you have paused, etc. That is up to you and I think it is out of the scope of the original question. – allo86 Jul 25 '17 at 20:48
  • I'm almost positive unfortunately that this is not the right way to go because it would not allow reconnecting with the same service again, only spawning new ones -- need to be able to send information to the service – user8364053 Jul 25 '17 at 22:50
  • Please read the Service documentation. You only have one Service instance and can deliver information using startService(). – allo86 Jul 25 '17 at 22:55
  • So I'd call startService() and it would communicate with the same service even if it's already started? e.g. if I wanted to pause the existing timer? – user8364053 Jul 25 '17 at 23:00
  • Exactly. Please read the documentation https://developer.android.com/guide/components/services.html#StartingAService. – allo86 Jul 26 '17 at 01:06
0

Another option is to use FirebaseJobDispatcher

Here is an example:

// Create a new dispatcher using the Google Play driver.
FirebaseJobDispatcher dispatcher = new FirebaseJobDispatcher(new GooglePlayDriver(context));
Bundle myExtrasBundle = new Bundle();
myExtrasBundle.putString("some_key", "some_value");

Job myJob = dispatcher.newJobBuilder()
    // the JobService that will be called
    .setService(MyJobService.class)
    // uniquely identifies the job
    .setTag("my-unique-tag")
    // one-off job
    .setRecurring(false)
    // don't persist past a device reboot
    .setLifetime(Lifetime.UNTIL_NEXT_BOOT)
    // start between 0 and 60 seconds from now
    .setTrigger(Trigger.executionWindow(0, 60))
    // don't overwrite an existing job with the same tag
    .setReplaceCurrent(false)
    // retry with exponential backoff
    .setRetryStrategy(RetryStrategy.DEFAULT_EXPONENTIAL)
    // constraints that need to be satisfied for the job to run
    .setConstraints(
        // only run on an unmetered network
        Constraint.ON_UNMETERED_NETWORK,
        // only run when the device is charging
        Constraint.DEVICE_CHARGING
    )
    .setExtras(myExtrasBundle)
    .build();

dispatcher.mustSchedule(myJob);

To cancel that job:

dispatcher.cancel("my-unique-tag");

The tag is useful if you want to replace the previous job scheduled. Keep in mind that it has a time window so it is not as accurate as the AlarmManager.

Here you have a table that compares the different options you have:

https://github.com/firebase/firebase-jobdispatcher-android#comparison-to-other-libraries

Inside that table you will find Evernote's library. It doesn't depend on Google Play and is a custom implementation that use different strategies depending on the user's OS you app is running on.

Leandro Ocampo
  • 1,894
  • 18
  • 38
  • I am not using Firebase, and I do need it to be reasonably accurate (at least in terms of seconds) – user8364053 Jul 25 '17 at 17:20
  • you don't need to use firebase. In fact I added that library using GCM in my project. – Leandro Ocampo Jul 25 '17 at 17:21
  • What is GCM and how do you add it? And what is `dispatcher`? – user8364053 Jul 25 '17 at 17:24
  • I am also unclear on `.setTrigger(Trigger.executionWindow(0, 60))`, like what if I want to set the timer to do something 97 seconds after pressing Start? Would that be `.setTrigger(Trigger.executionWindow(0, 97))`? – user8364053 Jul 25 '17 at 17:25
  • Firstly, you don't need Firebase nor GCM.. just that library. If your min sdk is api 21 you can use JobScheduler (native component). Secondly, those numbers are in seconds. I have updated the answer. Give it a try. – Leandro Ocampo Jul 25 '17 at 17:27
  • No,.,if you want to set the timer to do something 97 seconds after pressing Start then you should write this: .setTrigger(Trigger.executionWindow(97, 98))... it starts the job 97 seconds after and gives a window of 1 second. – Leandro Ocampo Jul 25 '17 at 17:33