androidandroid-widgetlocationmanagerandroid-gpsandroid-doze

Android get Location in app widget - locationManager seems to stop working after a while


TL:DR;

Short Story

An App Widget, in the home screen, fails to get a GPS position from an IntentService that uses LocationManager::getLastKnownLocation because after a while that the App is in the background or loses focus, the Location returned is null, such as there was no last position known.
I tried using Service, WorkerManager, AlarmManager and requesting a WakeLock with no success.


Situation

I am developing an Android application that reads public data and, after a few computations, shows them, formatted in a user-friendly way, to the user.
The service is a .json publicly available that contains data about weather status in my local area. Mostly it is an array with some (no more than 20) very simple records. Those records are updated every 5 minutes.
Included with the App I added an App Widget. What the widget does is to show a single (computed) value to the user. That gets a once-in-a-while update from the Android System (as specified by android:updatePeriodMillis="1800000"), and also listen to user interactions (tap) to send an update request.
The user can choose between a few widget types, each showing a different value, but all with the same tap-to-update behaviour.

Environment

.gradle config file:

defaultConfig {
    applicationId "it.myApp"
    versionCode code
    versionName "0.4.0"
    minSdkVersion 26
    targetSdkVersion 30
}

The Goal

What I want to add is an App Widget type that allows the user to get Location-aware data.
The ideal result would be that, once added on the home screen, the App Widget will listen to user interaction (tap) and when tapped will ask the required data.
This can be either done receiving the ready-to-show computed value or receiving the Location and a list of Geolocated Data to be compared, then create the value to show.

Implementation Process and errors

This, in order, is what I tried and the problems I got.

The LocationManager.requestSingleUpdate idea

The first thing I tried, knowing that I won't need a continuous update of the position because of the infrequent update of the raw data, was to call in the clickListener of the widget directly the LocationManager.requestSingleUpdate. I was unable to have any valid result with various errors so, surfing the Holy StackOverflow I understood that doing like so was not what an App Widget was intended.
So I switched to an Intent-based process.


The IntentService:

I implemented an IntentService, with all the startForegroundService related problems.
After many struggles, I run the Application and the widget was calling the service. But my location was not sent back, nor was the custom GPS_POSITION_AVAILABLE action and I could not understand why until a something flashed in my mind, the service was dying or dead when the callback was called.
So I understood that an IntentService was not what I should have used. I then switched to a standard Service based process.


The Service attempt:
Not to mention the infinite problems in getting the service running, I came to this class:

public class LocService extends Service {

    public static final String         ACTION_GET_POSITION       = "GET_POSITION";
    public static final String         ACTION_POSITION_AVAILABLE = "GPS_POSITION_AVAILABLE";
    public static final String         ACTUAL_POSITION           = "ACTUAL_POSITION";
    public static final String         WIDGET_ID                 = "WIDGET_ID";
    private             Looper         serviceLooper;
    private static      ServiceHandler serviceHandler;

    public static void startActionGetPosition(Context context,
                                              int widgetId) {
        Intent intent = new Intent(context, LocService.class);
        intent.setAction(ACTION_GET_POSITION);
        intent.putExtra(WIDGET_ID, widgetId);
        context.startForegroundService(intent);
    }

    // Handler that receives messages from the thread
    private final class ServiceHandler extends Handler {

        public ServiceHandler(Looper looper) {
            super(looper);
        }

        @Override
        public void handleMessage(Message msg) {
            if (LocService.this.checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION)
                    != PackageManager.PERMISSION_GRANTED && LocService.this.checkSelfPermission(
                    Manifest.permission.ACCESS_COARSE_LOCATION) != PackageManager.PERMISSION_GRANTED) {
                Toast.makeText(LocService.this, R.string.cannot_get_gps, Toast.LENGTH_SHORT)
                     .show();

            } else {
                LocationManager locationManager = (LocationManager) LocService.this.getSystemService(Context.LOCATION_SERVICE);
                Criteria criteria = new Criteria();
                criteria.setAccuracy(Criteria.ACCURACY_FINE);
                final int widgetId = msg.arg2;
                final int startId = msg.arg1;
                locationManager.requestSingleUpdate(criteria, location -> {
                    Toast.makeText(LocService.this, "location", Toast.LENGTH_SHORT)
                         .show();
                    Intent broadcastIntent = new Intent(LocService.this, TideWidget.class);
                    broadcastIntent.setAction(ACTION_POSITION_AVAILABLE);
                    broadcastIntent.putExtra(ACTUAL_POSITION, location);
                    broadcastIntent.putExtra(WIDGET_ID, widgetId);
                    LocService.this.sendBroadcast(broadcastIntent);
                    stopSelf(startId);
                }, null);
            }
        }
    }

    @Override
    public void onCreate() {
        HandlerThread thread = new HandlerThread("ServiceStartArguments");
        thread.start();
        if (Build.VERSION.SDK_INT >= 26) {
            String CHANNEL_ID = "my_channel_01";
            NotificationChannel channel = new NotificationChannel(CHANNEL_ID, "Channel human readable title",
                                                                  NotificationManager.IMPORTANCE_NONE);

            ((NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE)).createNotificationChannel(channel);

            Notification notification = new NotificationCompat.Builder(this, CHANNEL_ID).setContentTitle("")
                                                                                        .setContentText("")
                                                                                        .build();

            startForeground(1, notification);
        }

        // Get the HandlerThread's Looper and use it for our Handler
        serviceLooper = thread.getLooper();
        serviceHandler = new ServiceHandler(serviceLooper);
    }

    @Override
    public int onStartCommand(Intent intent,
                              int flags,
                              int startId) {
        int appWidgetId = intent.getIntExtra(WIDGET_ID, -1);
        Toast.makeText(this, "Waiting GPS", Toast.LENGTH_SHORT)
             .show();
        Message msg = serviceHandler.obtainMessage();
        msg.arg1 = startId;
        msg.arg2 = appWidgetId;
        serviceHandler.sendMessage(msg);

        return START_STICKY;
    }

    @Override
    public IBinder onBind(Intent intent) {
        return null;
    }

    @Override
    public void onDestroy() {
        Toast.makeText(this, "DONE", Toast.LENGTH_SHORT)
             .show();
    }
}

In which I had to use some workarounds like LocService.this. to access some kind of params or calling final my Message params to be used inside the Lambda.

Everything seems fine, I was getting a Location, I was able to send it back to the widget with an Intent, there was a little thing that I didn't like but I could very well have lived with that. I am talking about the notification that showed briefly in the phone telling the user that the service was running, not a big deal, if it was running was for a user input, not quite fancy to see but viable.

Then I came to a weird problem, I tapped the widget, a start Toast told me that the service was indeed started but then the notification didn't go away. I waited, then closed the app with "close all" of my phone.
I tried again and the widget seemed to be working. Until, the service got stuck again. So I opened my application to see if the data was processed and "tah dah" I immediately got the next Toast of the service "unfreezing".
I came to the conclusion that my Service was working, but ad some point, while the app was out of focus for a while (obviously when using the widget), the service froze. Maybe for Android's Doze or for an App Standby, I was not sure. I read some more and I found out that maybe a Worker and WorkerManager could bypass Android background services limitations.


The Worker way:

So I went for another change and implemented a Worker and this is what I got:

public class LocationWorker extends Worker {

    String LOG_TAG = "LocationWorker";
    public static final String ACTION_GET_POSITION       = "GET_POSITION";
    public static final String ACTION_POSITION_AVAILABLE = "GPS_POSITION_AVAILABLE";
    public static final String ACTUAL_POSITION           = "ACTUAL_POSITION";
    public static final String WIDGET_ID                 = "WIDGET_ID";

    private Context         context;
    private MyHandlerThread mHandlerThread;

    public LocationWorker(@NonNull Context context,
                          @NonNull WorkerParameters workerParams) {
        super(context, workerParams);
        this.context = context;
    }

    @NonNull
    @Override
    public Result doWork() {
        Log.e(LOG_TAG, "doWork");
        CountDownLatch countDownLatch = new CountDownLatch(2);
        mHandlerThread = new MyHandlerThread("MY_THREAD");
        mHandlerThread.start();

        Runnable runnable = new Runnable() {
            @Override
            public void run() {
                if (context.checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION)
                        != PackageManager.PERMISSION_GRANTED && context.checkSelfPermission(
                        Manifest.permission.ACCESS_COARSE_LOCATION) != PackageManager.PERMISSION_GRANTED) {
                    Log.e("WORKER", "NO_GPS");
                } else {
                    countDownLatch.countDown();
                    LocationManager locationManager = (LocationManager) context.getSystemService(
                            Context.LOCATION_SERVICE);
                    Criteria criteria = new Criteria();
                    criteria.setAccuracy(Criteria.ACCURACY_FINE);
                    locationManager.requestSingleUpdate(criteria, new LocationListener() {
                            @Override
                            public void onLocationChanged(@NonNull Location location) {
                                Log.e("WORKER", location.toString());
                                Intent broadcastIntent = new Intent(context, TideWidget.class);
                                broadcastIntent.setAction(ACTION_POSITION_AVAILABLE);
                                broadcastIntent.putExtra(ACTUAL_POSITION, location);
                                broadcastIntent.putExtra(WIDGET_ID, 1);
                                context.sendBroadcast(broadcastIntent);
                            }
                        },  mHandlerThread.getLooper());
                }
            }
        };
        mHandlerThread.post(runnable);
        try {
            if (countDownLatch.await(5, TimeUnit.SECONDS)) {
                return Result.success();
            } else {
                Log.e("FAIL", "" + countDownLatch.getCount());
                return Result.failure();
            }
        } catch (InterruptedException e) {
            e.printStackTrace();
            return Result.failure();
        }
    }

    class MyHandlerThread extends HandlerThread {

        Handler mHandler;

        MyHandlerThread(String name) {
            super(name);
        }

        @Override
        protected void onLooperPrepared() {
            Looper looper = getLooper();
            if (looper != null) mHandler = new Handler(looper);
        }

        void post(Runnable runnable) {
            if (mHandler != null) mHandler.post(runnable);
        }
    }

    class MyLocationListener implements LocationListener {

        @Override
        public void onLocationChanged(final Location loc) {
            Log.d(LOG_TAG, "Location changed: " + loc.getLatitude() + "," + loc.getLongitude());
        }

        @Override
        public void onStatusChanged(String provider,
                                    int status,
                                    Bundle extras) {
            Log.d(LOG_TAG, "onStatusChanged");
        }

        @Override
        public void onProviderDisabled(String provider) {
            Log.d(LOG_TAG, "onProviderDisabled");
        }

        @Override
        public void onProviderEnabled(String provider) {
            Log.d(LOG_TAG, "onProviderEnabled");
        }
    }
}

In which I used a thread to be able to use the LocationManager otherwise I was having a "called on dead thread" error.
Needless to say that this was working (more ore less, I was not implementing the receiving side anymore), no notification was shown, but I was having the same problem as before, the only thing was that I understood that the problem was not in the Worker (or Service) itself but with the locationManager. After a while that the app was not focused (as I was watching the home screen waiting to tap my widget) locationManager stopped working, hanging my Worker, that was saved only by my countDownLatch.await(5, SECONDS).

Well, ok, maybe I could not get a live location while the app was out of focus, strange, but I can accept it. I could use:

The LocationManager.getLastKnownLocation phase:

So I switched back to my original IntentService that now was running synchrously so there was no problem in handling callbacks, and I was able to use the Intent pattern, that I like. The fact is that, once implemented the receiver side, I figured out that even the LocationManager.getLastKnownLocation stopped working after a while the app was out of focus. I thought it was impossible because I was not asking for a live location, so if few seconds ago my phone was able to return a lastKnownLocation it should be able to do so now. The concern should only be in how "old" my location is, not if I am getting a location.


EDIT: I just tried with AlarmManager that somewhere I read it can interact with the Doze and App Standby. Unfortunately neither that did the trick. This is a piece of code of what I used:

AlarmManager alarmManager = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE);
PendingIntent pendingIntent = PendingIntent.getService(context, 1, intent, PendingIntent.FLAG_NO_CREATE);
if (pendingIntent != null && alarmManager != null) {
    alarmManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, System.currentTimeMillis() + 500, pendingIntent);
}

EDIT2: I tried a different service location using googleApi, but, as usual, nothing changed. The service returns the correct position for a small period of time, then it freezes.
This the code:

final int startId = msg.arg1;
FusedLocationProviderClient mFusedLocationClient = LocationServices.getFusedLocationProviderClient(LocService.this);

mFusedLocationClient.getLastLocation().addOnSuccessListener(location -> {
    if (location != null) {
        Toast.makeText(LocService.this, location.toString(), Toast.LENGTH_SHORT)
            .show();
    } else {
        Toast.makeText(LocService.this, "NULL", Toast.LENGTH_SHORT)
            .show();
    }
    stopSelf(startId);
}).addOnCompleteListener(task -> {
    Toast.makeText(LocService.this, "COMPLETE", Toast.LENGTH_SHORT)
        .show();
    stopSelf(startId);
});

EDIT3: Obviously I can't wait updating StackOverflow, so I took a detour in a new direction, to try something different. The new try is about PowerManager, acquiring a WakeLock. In my mind that could have been a solution to avoid LocationManager stop working. No success still, tho.

PowerManager powerManager = (PowerManager) getSystemService(POWER_SERVICE);
PowerManager.WakeLock mWakeLock = null;
if (powerManager != null)
    mWakeLock = powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "TRY:");
if (mWakeLock != null)
    mWakeLock.acquire(TimeUnit.HOURS.toMillis(500));

The Solution

Well, I am stuck, I don't think at the moment I can fulfil this paragraph, any help can be of use.


Solution

  • It looks like you are hitting the restrictions on accessing background location added in android 10 and android 11. I think there are two possible workarounds:

    1. Get back to the foreground service implementation and set service type to location. As stated here a foreground service started from appwidget is not a subject to "while-in-use" restrictions.
    2. Get the background location access as described here. Starting from android 11 to get this access you need to direct users to app settings and they should grant it manually. Please note Google Play introduced a new privacy policy recently, so if you are going to publish your app on Google Play, you'll have to prove it's absolutely necessary for your app and get an approval