/**
* Copyright (C) 2013 Johannes Schnatterer
*
* See the NOTICE file distributed with this work for additional
* information regarding copyright ownership.
*
* This file is part of nusic.
*
* nusic is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* nusic is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with nusic. If not, see <http://www.gnu.org/licenses/>.
*/
package info.schnatterer.nusic.android.service;
import info.schnatterer.nusic.Constants;
import info.schnatterer.nusic.android.activities.MainActivity;
import info.schnatterer.nusic.android.util.ImageUtil;
import info.schnatterer.nusic.android.util.Notification;
import info.schnatterer.nusic.android.util.Notification.NotificationId;
import info.schnatterer.nusic.core.ConnectivityService;
import info.schnatterer.nusic.core.PreferencesService;
import info.schnatterer.nusic.core.ReleaseService;
import info.schnatterer.nusic.core.ServiceException;
import info.schnatterer.nusic.core.SyncReleasesService;
import info.schnatterer.nusic.core.event.ArtistProgressListener;
import info.schnatterer.nusic.data.DatabaseException;
import info.schnatterer.nusic.data.dao.ArtworkDao;
import info.schnatterer.nusic.data.dao.ArtworkDao.ArtworkType;
import info.schnatterer.nusic.data.model.Artist;
import info.schnatterer.nusic.data.model.Release;
import info.schnatterer.nusic.ui.R;
import java.io.InputStream;
import java.util.Calendar;
import java.util.Date;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Set;
import javax.inject.Inject;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import roboguice.receiver.RoboBroadcastReceiver;
import android.Manifest;
import android.annotation.TargetApi;
import android.app.AlarmManager;
import android.app.PendingIntent;
import android.app.Service;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.graphics.Bitmap;
import android.os.Binder;
import android.os.Build;
import android.os.Bundle;
import android.os.IBinder;
import android.support.v4.content.ContextCompat;
public class LoadNewReleasesService extends WakefulService {
/** Small icon shown in the status bar when a notification is shwown. */
private static final int NOTIFICATION_SMALL_ICON = R.drawable.ic_album_white_24dp;
private static final Logger LOG = LoggerFactory
.getLogger(LoadNewReleasesService.class);
/**
* Key to the creating intent's extras that contains a boolean that triggers
* loading the release if <code>true</code>.
*/
public static final String EXTRA_REFRESH_ON_START = "nusic.intent.extra.refreshOnStart";
@Inject
private PreferencesService preferencesService;
@Inject
private ConnectivityService connectivityService;
@Inject
private SyncReleasesService syncReleasesService;
@Inject
private ReleaseService releaseService;
@Inject
private LoadNewReleasesServiceConnectivityReceiver loadNewReleasesServiceConnectivityReceiver;
@Inject
private LoadNewReleasesServiceScheduler loadNewReleasesServiceScheduler;
@Inject
private ArtworkDao artworkDao;
// private int totalArtists = 0;
private ProgressListenerNotifications progressListenerNotifications = new ProgressListenerNotifications();
private LoadNewReleasesServiceBinder binder = new LoadNewReleasesServiceBinder();
/**
* We're only going to allow one execution at a time.
*/
private Thread workerThread = null;
@Override
public int onStartCommandWakeful(Intent intent, int flags, int startId) {
LOG.debug(
"Flags = {}; startId = {}. Intent = {}",
flags,
startId,
intent,
(intent != null ? (", extra " + EXTRA_REFRESH_ON_START + " = " + intent
.getBooleanExtra(EXTRA_REFRESH_ON_START, false)) : ""));
boolean refreshing = true;
if (intent == null) {
// When START_STICKY the intent will be null on "restart" after
// getting killed
// TODO RESUME download instead of starting new?
LOG.debug("Services restarted after being destroyed while workerThread was running.");
refreshReleases(false, null);
} else if (intent.getBooleanExtra(EXTRA_REFRESH_ON_START, false)) {
refreshReleases(false, null);
} else {
refreshing = false;
}
/*
* Don't release wake lock after this method ends, as the logic runs in
* a separate thread.
*
* The lock is release onDestroy().
*/
if (refreshing) {
keepLock = true;
}
return Service.START_STICKY;
}
/**
* Tries to start refreshing releases. If refresh is already in progress,
* attaches <code>artistProcessedListener</code> to it and returns
* <code>false</code>.
*
* @param updateOnlyIfNecessary
* if <code>true</code> the refresh is only done when
* {@link SyncReleasesService#isUpdateNeccesarry()} returns
* <code>true</code>. Otherwise, the refresh is done at any case.
* @param artistProcessedListener
* @return <code>true</code> if refresh was started. <code>false</code> if
* already in progress.
*/
public boolean refreshReleases(boolean updateOnlyIfNecessary,
ArtistProgressListener artistProcessedListener) {
if (tryCreateThread(updateOnlyIfNecessary, artistProcessedListener)) {
return true;
} else {
LOG.debug("Service thread already working, only adding process listener");
syncReleasesService
.addArtistProcessedListener(artistProcessedListener);
return false;
}
}
/**
* Synchronizes the creation and starting of new {@link #workerThread}s.
*
* @param updateOnlyIfNecessary
* @param artistProcessedListener
* @return <code>true</code> if thread was started, <code>false</code>
* otherwise.
*/
private synchronized boolean tryCreateThread(boolean updateOnlyIfNecessary,
ArtistProgressListener artistProcessedListener) {
if (workerThread != null) {
return false;
}
LOG.debug("Service thread not working yet, starting.");
workerThread = new Thread(new WorkerThread(updateOnlyIfNecessary, artistProcessedListener));
workerThread.start();
return true;
}
private class WorkerThread implements Runnable {
private boolean updateOnlyIfNecesary;
private ArtistProgressListener artistProgressListener;
public WorkerThread(boolean updateOnlyIfNecessary,
ArtistProgressListener artistProgressListener) {
this.updateOnlyIfNecesary = updateOnlyIfNecessary;
this.artistProgressListener = artistProgressListener;
}
public void run() {
LOG.debug("Service thread starting work");
if (!connectivityService.isOnline()) {
handleOffline();
} else {
// Make sure any changes to the online state are ignored
loadNewReleasesServiceConnectivityReceiver.disableReceiver();
if (!updateOnlyIfNecesary && hasReadPermissionOrNotify()) {
syncReleasesService
.addArtistProcessedListener(artistProgressListener);
syncReleasesService
.addArtistProcessedListener(progressListenerNotifications);
long beforeRefresh = System.currentTimeMillis();
LOG.debug("Service thread: Calling refreshReleases()");
syncReleasesService.syncReleases();
// Schedule next run
loadNewReleasesServiceScheduler.schedule(
preferencesService.getRefreshPeriod(), null);
try {
notifyNewReleases(beforeRefresh);
} catch (ServiceException e) {
// Refresh succeeded, so don't tell user
LOG.warn(
"Refresh succeeded, but database error when trying to find out about new releases",
e);
}
// Remove all listeners
syncReleasesService.removeArtistProcessedListeners();
}
}
// stop service
LOG.debug("Service: Explicit stop self");
stopSelf();
}
private void handleOffline() {
LOG.debug("Service thread: Not online!");
// If not online and update necessary, postpone run
if (!updateOnlyIfNecesary) {
LOG.debug("Postponing service until online or next schedule");
loadNewReleasesServiceConnectivityReceiver.enableReceiver();
// Send status "not online" back to listener?
if (artistProgressListener != null) {
// TODO find a solution without ServiceException here!
artistProgressListener.onProgressFailed(null, 0, 0,
null,
new ServiceException("R.string.NotOnline") {
private static final long serialVersionUID = 1L;
@Override
public String getLocalizedMessage() {
return LoadNewReleasesService.this
.getString(R.string.NotOnline);
}
});
}
} else {
// Make sure any changes to the online state are ignored
loadNewReleasesServiceConnectivityReceiver.disableReceiver();
}
}
/**
* If necessary (depending on the SDK versin of the device), checks if
* the {@link Manifest.permission#READ_EXTERNAL_STORAGE} is present. If
* it is not present, notifies an error. <br/>
*
* This would be better be done in the nusic-ui-android package, but for
* now cannot use aar dependencies there (because the
* android-maven-plugin does not support jars). Pragmatic soulution:
* Check the permission right here in the service.
*
* @return <code>true</code> if the permission is present or checking
* was not necessary. Otherwise <code>false</code>.
*/
private boolean hasReadPermissionOrNotify() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
boolean permissionGranted = isReadExternalStoragePermissionGranted();
if (!permissionGranted) {
/*
* TODO a tap on the notifcation opens the trying to request
* the permission again
*/
Notification
.notifyWarning(
LoadNewReleasesService.this,
LoadNewReleasesService.this
.getString(R.string.LoadNewReleasesService_missingPermissionReadExternalStorage));
}
return permissionGranted;
} else {
return true;
}
}
/**
* Checks if the permission for is present.
*
* @return <code>true</code> if the permission is present. Otherwise
* <code>false</code>.
*/
@TargetApi(Build.VERSION_CODES.M)
private boolean isReadExternalStoragePermissionGranted() {
LOG.debug("Checking if read external storage permission is set");
boolean permissionGranted = ContextCompat.checkSelfPermission(
LoadNewReleasesService.this,
Manifest.permission.READ_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED;
LOG.debug("External storage permission = {}", permissionGranted);
return permissionGranted;
}
}
/**
* Finds which releases are new to the device and notifies user if enabled
* in preferences.
*
* @param beforeRefresh
* @throws DatabaseException
*/
private void notifyNewReleases(long beforeRefresh) throws ServiceException {
if (preferencesService.isEnabledNotifyNewReleases()) {
List<Release> newReleases = releaseService
.findByDateCreatedGreaterThan(beforeRefresh);
if (newReleases.size() == 1) {
notifyNewReleases(newReleases.get(0));
} else if (newReleases.size() > 0) {
notifyNewReleases(newReleases.size());
}
}
}
/**
* Puts out a notification informing about one release that was just found.<br/>
* <br/>
* <br/>
* Future calls overwrite any previous instances of this notification still
* on display.
*
* @param release
*
*/
private void notifyNewReleases(Release release) {
try {
Bitmap scaledBitmap = null;
InputStream artworkStream = artworkDao.findStreamByRelease(release,
ArtworkType.SMALL);
if (artworkStream != null) {
scaledBitmap = ImageUtil
.createScaledBitmap(artworkStream, this);
}
Notification.notify(
this,
NotificationId.NEW_RELEASE,
getString(R.string.LoadNewReleasesService_newRelease),
release.getArtist().getArtistName() + " - "
+ release.getReleaseName(),
NOTIFICATION_SMALL_ICON, scaledBitmap, MainActivity.class,
createExtraActiveTab());
} catch (DatabaseException e) {
LOG.warn("Unable to load artwork for notification. " + release, e);
} catch (IllegalArgumentException e) {
LOG.warn("Unable scale artwork for notification. " + release, e);
}
}
/**
* Puts out a notification informing about multiple releases that were just
* found<br/>
* <br/>
* Future calls overwrite any previous instances of this notification still
* on display.
*
* @param nReleases
* the number of releases published today
*/
private void notifyNewReleases(int nReleases) {
Notification.notify(this, NotificationId.NEW_RELEASE, String.format(
getString(R.string.LoadNewReleasesService_newReleaseMultiple),
nReleases), null, NOTIFICATION_SMALL_ICON, null,
MainActivity.class, createExtraActiveTab());
}
/**
* Creates an extra bundle that contains the tab to be shown when
* {@link MainActivity} is launched.
*
* @return
*/
private Bundle createExtraActiveTab() {
Bundle extras = new Bundle();
extras.putSerializable(MainActivity.EXTRA_ACTIVE_TAB,
MainActivity.TabDefinition.JUST_ADDED);
return extras;
}
/**
* Creates an intent that, when started as service, directly calls
* {@link #refreshReleases(boolean, ArtistProgressListener)}.
*
* @return
*/
public static Intent createIntentRefreshReleases(Context context) {
Intent intent = new Intent(context, LoadNewReleasesService.class);
intent.putExtra(EXTRA_REFRESH_ON_START, true);
return intent;
}
@Override
public IBinder onBind(Intent intent) {
return binder;
}
@Override
public void onDestroy() {
LOG.debug("Nusic service: onDestroy()");
if (workerThread != null && workerThread.isAlive()) {
LOG.debug("Services destroyed while workerThread is running.");
}
workerThread = null;
if (syncReleasesService != null) {
syncReleasesService
.removeArtistProcessedListener(progressListenerNotifications);
}
releaseLock(this.getApplicationContext());
}
public boolean isRunning() {
return workerThread != null && workerThread.isAlive();
}
/**
* Class used for the client Binder. Because we know this service always
* runs in the same process as its clients, we don't need to deal with IPC.
*/
public class LoadNewReleasesServiceBinder extends Binder {
public LoadNewReleasesService getService() {
return LoadNewReleasesService.this;
}
}
/**
* Broadcast receiver that triggers execution of
* {@link LoadNewReleasesService} after a scheduled alarm.
*
* @author schnatterer
*
*/
public static class LoadNewReleasesServiceAlarmReceiver extends
RoboBroadcastReceiver {
@Override
public void handleReceive(final Context context, final Intent intent) {
LOG.debug("Alarm Receiver: Alarm received!");
// Acquire lock, making sure device is not going to sleep again
acquireLock(context);
context.startService(LoadNewReleasesService
.createIntentRefreshReleases(context));
}
}
/**
* Progress listeners that displays any crucial info as android
* notification.
*
* @author schnatterer
*
*/
private class ProgressListenerNotifications implements
ArtistProgressListener {
private int totalArtists = 0;
private List<Artist> errorArtists;
private Set<String> exceptions;
@Override
public void onProgressStarted(int nEntities) {
errorArtists = new LinkedList<>();
exceptions = new HashSet<>();
totalArtists = nEntities;
}
@Override
public void onProgress(Artist entity, int progress, int max,
Throwable potentialException) {
if (potentialException != null) {
if (potentialException instanceof ServiceException) {
errorArtists.add(entity);
ServiceException serviceException = (ServiceException) potentialException;
if (serviceException.getCause() != null) {
exceptions.add(serviceException.getCause().toString());
} else {
exceptions.add(serviceException.toString());
}
} else {
exceptions.add(potentialException.toString());
}
}
}
@Override
public void onProgressFinished(Boolean result) {
if (errorArtists != null && errorArtists.size() > 0 && preferencesService.isNotifyRefreshErrors()) {
Notification.notifyWarning(LoadNewReleasesService.this,
R.string.LoadNewReleasesBinding_finishedWithErrors,
errorArtists.size(), totalArtists);
LOG.warn("Loading releases finished with errors for {}/{} artists",
errorArtists.size(), totalArtists);
LOG.warn("{} different exceptions while loading releases: {}", exceptions.size(),
exceptions);
}
}
@Override
public void onProgressFailed(Artist entity, int progress, int max,
Boolean result, Throwable potentialException) {
if (potentialException == null) {
return;
}
LOG.error(potentialException.getMessage(), potentialException);
if (potentialException instanceof ServiceException) {
Notification.notifyWarning(LoadNewReleasesService.this,
getString(R.string.LoadNewReleasesBinding_errorFindingReleases)
+ potentialException.getLocalizedMessage());
} else {
Notification.notifyWarning(LoadNewReleasesService.this,
getString(R.string.LoadNewReleasesBinding_errorFindingReleasesGeneric)
+ potentialException.getClass().getSimpleName());
}
}
}
public static class LoadNewReleasesServiceScheduler {
@Inject
private Context context;
@Inject
private PreferencesService preferencesService;
/**
* Schedule this task to run regularly.
*
* @param triggerAt
* if <code>null</code>, the first start will be now +
* <code>intervalDays</code>
*/
public void schedule(int intervalDays, Date triggerAt) {
Date triggerAtDate = triggerAt;
if (triggerAt == null) {
Calendar triggerAtCal = Calendar.getInstance();
triggerAtCal.add(Calendar.DAY_OF_MONTH, intervalDays);
// triggerAtCal.add(Calendar.SECOND, 60);
triggerAtDate = triggerAtCal.getTime();
}
// debug: Starts service once per minute
// triggerAtDate = DateUtils.addMinutes(60);
// // Start service directly, on alarm
// PendingIntent pintent = PendingIntent.getService(context, 0,
// createIntentRefreshReleases(context),
// PendingIntent.FLAG_UPDATE_CURRENT);
/*
* Start service directly via receiver that acquires a wake lock, in
* order to avoid the device falling back to sleep before service is
* started
*/
PendingIntent pintent = PendingIntent.getBroadcast(context,
Constants.Alarms.NEW_RELEASES.ordinal(),
new Intent(context,
LoadNewReleasesServiceAlarmReceiver.class),
PendingIntent.FLAG_UPDATE_CURRENT);
AlarmManager alarm = (AlarmManager) context
.getSystemService(Context.ALARM_SERVICE);
/*
* Set a repeating schedule, so there always is a next alarm even
* when one alarm should fail for some reason
*/
alarm.setInexactRepeating(AlarmManager.RTC,
triggerAtDate.getTime(), AlarmManager.INTERVAL_DAY
* intervalDays, pintent);
preferencesService.setNextReleaseRefresh(triggerAtDate);
LOG.debug("Scheduled task to run again every " + intervalDays
+ " days, starting at " + triggerAtDate);
}
}
}