1

According this thread on stackoverflow it should be possible to manage notification from outside main/UI thread. And it actually is. I'm creating notification in SyncAdapter to notify user that background sync started and updating upload progress and after upload is finished I'm canceling notification after some defined timeout. My problem is that notification auto cancelling is not predictable. Sometimes it auto cancels itself ok, sometimes it is visible until next sync.

Here is the whole Adapter:

package com.marianhello.bgloc.sync;

import android.accounts.Account;
import android.app.NotificationManager;
import android.content.AbstractThreadedSyncAdapter;
import android.content.ContentProviderClient;
import android.content.ContentResolver;
import android.content.Context;
import android.content.SyncResult;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.support.v4.app.NotificationCompat;

import com.marianhello.bgloc.Config;
import com.marianhello.bgloc.HttpPostService;
import com.marianhello.bgloc.UploadingCallback;
import com.marianhello.bgloc.data.ConfigurationDAO;
import com.marianhello.bgloc.data.DAOFactory;
import com.marianhello.logging.LoggerManager;

import org.json.JSONException;

import java.io.File;
import java.io.IOException;
import java.net.HttpURLConnection;
import java.text.SimpleDateFormat;
import java.util.Calendar;
import java.util.HashMap;

/**
 * Handle the transfer of data between a server and an
 * app, using the Android sync adapter framework.
 */
public class SyncAdapter extends AbstractThreadedSyncAdapter implements UploadingCallback {

    private static final int NOTIFICATION_ID = 1;

    ContentResolver contentResolver;
    private ConfigurationDAO configDAO;
    private NotificationManager notifyManager;
    private BatchManager batchManager;

    private org.slf4j.Logger log;

    /**
     * Set up the sync adapter
     */
    public SyncAdapter(Context context, boolean autoInitialize) {
        super(context, autoInitialize);
        log = LoggerManager.getLogger(SyncAdapter.class);

        /*
         * If your app uses a content resolver, get an instance of it
         * from the incoming Context
         */
        contentResolver = context.getContentResolver();
        configDAO = DAOFactory.createConfigurationDAO(context);
        batchManager = new BatchManager(this.getContext());
        notifyManager = (NotificationManager) getContext().getSystemService(Context.NOTIFICATION_SERVICE);
    }


    /**
     * Set up the sync adapter. This form of the
     * constructor maintains compatibility with Android 3.0
     * and later platform versions
     */
    public SyncAdapter(
            Context context,
            boolean autoInitialize,
            boolean allowParallelSyncs) {
        super(context, autoInitialize, allowParallelSyncs);

        log = LoggerManager.getLogger(SyncAdapter.class);

        /*
         * If your app uses a content resolver, get an instance of it
         * from the incoming Context
         */
        contentResolver = context.getContentResolver();
        configDAO = DAOFactory.createConfigurationDAO(context);
        batchManager = new BatchManager(this.getContext());
        notifyManager = (NotificationManager) getContext().getSystemService(Context.NOTIFICATION_SERVICE);
    }

    /*
     * Specify the code you want to run in the sync adapter. The entire
     * sync adapter runs in a background thread, so you don't have to set
     * up your own background processing.
     */
    @Override
    public void onPerformSync(
            Account account,
            Bundle extras,
            String authority,
            ContentProviderClient provider,
            SyncResult syncResult) {

        Config config = null;
        try {
            config = configDAO.retrieveConfiguration();
        } catch (JSONException e) {
            log.error("Error retrieving config: {}", e.getMessage());
        }

        if (config == null) return;

        log.debug("Sync request: {}", config.toString());
        if (config.hasUrl() || config.hasSyncUrl()) {
            Long batchStartMillis = System.currentTimeMillis();

            File file = null;
            try {
                file = batchManager.createBatch(batchStartMillis);
            } catch (IOException e) {
                log.error("Failed to create batch: {}", e.getMessage());
            }

            if (file == null) {
                log.info("Nothing to sync");
                return;
            }

            log.info("Syncing batchStartMillis: {}", batchStartMillis);
            String url = config.hasSyncUrl() ? config.getSyncUrl() : config.getUrl();
            HashMap<String, String> httpHeaders = new HashMap<String, String>();
            httpHeaders.putAll(config.getHttpHeaders());
            httpHeaders.put("x-batch-id", String.valueOf(batchStartMillis));

            if (uploadLocations(file, url, httpHeaders)) {
                log.info("Batch sync successful");
                batchManager.setBatchCompleted(batchStartMillis);
                if (file.delete()) {
                    log.info("Batch file has been deleted: {}", file.getAbsolutePath());
                } else {
                    log.warn("Batch file has not been deleted: {}", file.getAbsolutePath());
                }
            } else {
                log.warn("Batch sync failed due server error");
                syncResult.stats.numIoExceptions++;
            }
        }
    }

    private boolean uploadLocations(File file, String url, HashMap httpHeaders) {
        NotificationCompat.Builder builder = new NotificationCompat.Builder(getContext());
        builder.setOngoing(true);
        builder.setContentTitle("Syncing locations");
        builder.setContentText("Sync in progress");
        builder.setSmallIcon(android.R.drawable.ic_dialog_info);
        notifyManager.notify(NOTIFICATION_ID, builder.build());

        try {
            int responseCode = HttpPostService.postJSON(url, file, httpHeaders, this);
            if (responseCode == HttpURLConnection.HTTP_OK) {
                builder.setContentText("Sync completed");
            } else {
                builder.setContentText("Sync failed due server error");
            }

            return responseCode == HttpURLConnection.HTTP_OK;
        } catch (IOException e) {
            log.warn("Error uploading locations: {}", e.getMessage());
            builder.setContentText("Sync failed: " + e.getMessage());
        } finally {
            builder.setOngoing(false);
            builder.setProgress(0, 0, false);
            builder.setAutoCancel(true);
            notifyManager.notify(NOTIFICATION_ID, builder.build());

            Handler h = new Handler(Looper.getMainLooper());
            long delayInMilliseconds = 5000;
            h.postDelayed(new Runnable() {
                public void run() {
                    notifyManager.cancel(NOTIFICATION_ID);
                }
            }, delayInMilliseconds);
        }

        return false;
    }

    public void uploadListener(int progress) {
        NotificationCompat.Builder builder = new NotificationCompat.Builder(getContext());
        builder.setOngoing(true);
        builder.setContentTitle("Syncing locations");
        builder.setContentText("Sync in progress");
        builder.setSmallIcon(android.R.drawable.ic_dialog_info);
        builder.setProgress(100, progress, false);
        notifyManager.notify(NOTIFICATION_ID, builder.build());
    }
}

The whole project is OSS so full source code is available. To get bigger picture also HttpPostService.java might interesting.

Community
  • 1
  • 1
mauron85
  • 1,084
  • 12
  • 22
  • Do you experience nondeterministic auto cancelling when calling notifyManager.cancel() directly, without delay posting a Runnable to the main thread? – Bryan Dunlap Aug 10 '16 at 18:07
  • Don't remember trying that. Are you pointing to Handler h? – mauron85 Aug 10 '16 at 18:08
  • Yes. It's possible to manage notification from other than main thread, but you really shouldn't manage from multiple threads unless you're using some sort of locking mechanism. Just manage the entire thing from the background thread that your AsyncTask is running in. – Bryan Dunlap Aug 10 '16 at 18:13
  • Thanks. That's how I actually started. But I don't want to cancel notification immediately when upload is finished, but after delayInMilliseconds which is 5secs. The only way how to use timer in non main thread I've found was Handler and postDelayedMessage. If I remember correclty without it I used to get error: "Can't create handler inside thread that has not called Looper.prepare()" – mauron85 Aug 10 '16 at 18:19

2 Answers2

2

I think your issue is the following: you post notification cancel on UI thread, but in parallel you post updates on background thread. There is race condition between cancellation and the last update(s) - sometimes cancellation is the last command that notification manager gets, and sometimes it receives additional update(s) after cancellation (which makes him pop up the notification again).

Why do you post cancellation on the main thread in the first place? Just check the status in uploadListener(int) and decide whether you want to update the notification or cancel it...

Vasiliy
  • 14,686
  • 5
  • 56
  • 105
  • The reason for wrapping cancellation in postDelayed handler method is obviously cancelation delay. I don't want to cancel notification immediately when upload is finished, but after delayInMilliseconds which is 5secs. – mauron85 Aug 10 '16 at 18:15
  • @mauron85, and you are ok with cancelling the notification before the sync is completed (if it takes more than 5 sec)? – Vasiliy Aug 10 '16 at 18:17
  • @mauron85, like I said above, try calling notifyManager.cancel() directly from your finally block. I'm pretty certain your nondeterministic behavior disappears. – Bryan Dunlap Aug 10 '16 at 18:18
  • @Vasiliy cancelation should never happen before sync is completed. I've added link to HttpPostService.java. It is synchronous. So notifyManager.cancel is only called when sync/http post is 100% completed or failed. – mauron85 Aug 10 '16 at 18:29
  • @BryanDunlap, I will try that, but kind of like to have that delay present. – mauron85 Aug 10 '16 at 18:30
  • @mauron85, I see. Now it looks really strange. I don't think that the issue is somehow related to usage of multiple threads now - NotificationManager is a system service, and I guess it runs as a bound IPC service, therefore the thread you use to call its methods is not the thread that will execute the actual code. Is there a chance that you have several syncs back-to-back (with less than 5 secs between them)? – Vasiliy Aug 10 '16 at 18:49
  • @Vasiliy SyncService lives in separated process android:process=":sync" and multiple parallel syncs are disabled android:allowParallelSyncs: false. So I believe it should not be concurrent syncs running because of that. – mauron85 Aug 10 '16 at 18:58
  • @mauron85, I didn't mean concurrent syncs, but syncs which are close in time. If a new sync starts before 5sec timeout passed, it will cause the notification to remain in place... – Vasiliy Aug 10 '16 at 19:11
  • @Vasiliy actually there is small chance that it might happen. Didn't thought about it before. I actually should check that out. Thanks for a lead. Will report result. – mauron85 Aug 10 '16 at 19:29
  • So just checked it out. Unfortunately not the case. I logged relevant events: Syncing startAt: 1470859234464 Syncing endAt: 1470859234614 Notification cancelled at: 1470859239664 And no other sync event. Notification was not cancelled, remain visible. – mauron85 Aug 10 '16 at 20:02
  • @BryanDunlap just did test with notifyManager.cancel() directly in finally block. Notification was not cancelled. Very strange. – mauron85 Aug 10 '16 at 20:13
  • I can't believe it. But this guy seems to solved it. http://stackoverflow.com/a/18517769/3896616. Changed notification id from 1 to 666 and dragons start flying. OMG Android what is wrong with you. – mauron85 Aug 10 '16 at 20:41
  • @mauron85, then you'd better post this as an answer - I want to be able to find it if I ever encounter the same Android bug ;) – Vasiliy Aug 10 '16 at 21:07
2

I've found solution to my problem in this stackoverflow thread.

When I changed NOTIFICATION_ID from 1 to [RANDOM_NUMBER], it magically started working. I assume that 1 is somehow reserved, although there is no note in any documentation...

An of course make sure you use the same NOTIFICATION_ID to cancel: notificationManager.cancel(NOTIFICATION_ID);

Community
  • 1
  • 1
mauron85
  • 1,084
  • 12
  • 22