/*
* Kontalk Android client
* Copyright (C) 2017 Kontalk Devteam <devteam@kontalk.org>
* This program 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.
* This program 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 this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.kontalk.service;
/*
* TODO instead of using a notification ID per type, use a notification ID per
* download.
*/
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import android.app.IntentService;
import android.app.Notification;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.content.ContentUris;
import android.content.ContentValues;
import android.content.Context;
import android.content.Intent;
import android.net.Uri;
import android.os.Bundle;
import android.support.v4.app.NotificationCompat;
import org.kontalk.Kontalk;
import org.kontalk.Log;
import org.kontalk.R;
import org.kontalk.client.ClientHTTPConnection;
import org.kontalk.client.EndpointServer;
import org.kontalk.crypto.Coder;
import org.kontalk.crypto.DecryptException;
import org.kontalk.crypto.PersonalKey;
import org.kontalk.message.CompositeMessage;
import org.kontalk.provider.Keyring;
import org.kontalk.provider.MyMessages.Messages;
import org.kontalk.reporting.ReportingManager;
import org.kontalk.service.msgcenter.MessageCenterService;
import org.kontalk.ui.ConversationsActivity;
import org.kontalk.ui.MessagingNotification;
import org.kontalk.ui.ProgressNotificationBuilder;
import org.kontalk.util.MediaStorage;
import org.kontalk.util.Preferences;
import static org.kontalk.ui.MessagingNotification.NOTIFICATION_ID_DOWNLOADING;
import static org.kontalk.ui.MessagingNotification.NOTIFICATION_ID_DOWNLOAD_ERROR;
import static org.kontalk.ui.MessagingNotification.NOTIFICATION_ID_DOWNLOAD_OK;
/**
* The attachment download service.
* TODO implement multiple downloads in queue or in parallel
* @author Daniele Ricci
*/
public class DownloadService extends IntentService implements DownloadListener {
private static final String TAG = MessageCenterService.TAG;
/** A map to avoid duplicate downloads. */
private static final Map<String, Long> sQueue = new LinkedHashMap<>();
private static final String ACTION_DOWNLOAD_URL = "org.kontalk.action.DOWNLOAD_URL";
private static final String ACTION_DOWNLOAD_ABORT = "org.kontalk.action.DOWNLOAD_ABORT";
private static final String EXTRA_NOTIFY = "org.kontalk.download.notify";
private ProgressNotificationBuilder mNotificationBuilder;
private NotificationManager mNotificationManager;
// data about the download currently being processed
private Notification mCurrentNotification;
private long mTotalBytes;
private long mMessageId;
private String mPeer;
private boolean mEncrypted;
private boolean mNotify;
private ClientHTTPConnection mDownloadClient;
private boolean mCanceled;
public DownloadService() {
super(DownloadService.class.getSimpleName());
}
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
// crappy firmware - as per docs, intent can't be null in this case
if (intent != null) {
if (mNotificationManager == null)
mNotificationManager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
if (ACTION_DOWNLOAD_ABORT.equals(intent.getAction())) {
final Uri uri = intent.getData();
new Thread(new Runnable() {
@Override
public void run() {
onDownloadAbort(uri);
}
}).start();
}
}
return super.onStartCommand(intent, flags, startId);
}
@Override
protected void onHandleIntent(Intent intent) {
// crappy firmware - as per docs, intent can't be null in this case
if (intent == null)
return;
String action = intent.getAction();
if (ACTION_DOWNLOAD_URL.equals(action)) {
onDownloadURL(intent.getData(), intent.getExtras());
}
}
private void onDownloadURL(Uri uri, Bundle args) {
String url = uri.toString();
// check if download has already been queued
if (sQueue.get(url) != null) return;
// notify user about download immediately
startForeground(0);
mCanceled = false;
mNotify = args.getBoolean(EXTRA_NOTIFY, true);
if (mDownloadClient == null) {
mDownloadClient = new ClientHTTPConnection(this);
}
try {
// check if external storage is available
if (!MediaStorage.isExternalStorageAvailable()) {
errorNotification(getString(R.string.notify_ticker_external_storage),
getString(R.string.notify_text_external_storage));
return;
}
mMessageId = args.getLong(CompositeMessage.MSG_ID, 0);
mPeer = args.getString(CompositeMessage.MSG_SENDER);
mEncrypted = args.getBoolean(CompositeMessage.MSG_ENCRYPTED, false);
sQueue.put(url, mMessageId);
Date date;
long timestamp = args.getLong(CompositeMessage.MSG_TIMESTAMP);
if (timestamp > 0)
date = new Date(timestamp);
else
date = new Date();
String mime = args.getString(CompositeMessage.MSG_MIME);
// this will be used if the server doesn't provide one
// if the server provides a filename, only the path will be used
File defaultFile = CompositeMessage.getIncomingFile(mime, date);
if (defaultFile == null) {
defaultFile = MediaStorage.getIncomingFile(date, "bin");
}
// download content
mDownloadClient.downloadAutofilename(url, defaultFile, date, this);
}
catch (Exception e) {
error(url, null, e);
}
finally {
sQueue.remove(url);
mMessageId = 0;
mPeer = null;
}
}
void onDownloadAbort(Uri uri) {
String url = uri.toString();
Long msgId = sQueue.get(url);
if (msgId != null) {
// interrupt worker if running
if (msgId == mMessageId) {
mDownloadClient.abort();
mCanceled = true;
}
// remove from queue - will never be processed
else
sQueue.remove(url);
}
}
public void startForeground(long totalBytes) {
Log.d(TAG, "starting foreground progress notification");
mTotalBytes = totalBytes;
Intent ni = new Intent(getApplicationContext(), ConversationsActivity.class);
// FIXME this intent should actually open the ComposeMessage activity
PendingIntent pi = PendingIntent.getActivity(getApplicationContext(),
NOTIFICATION_ID_DOWNLOADING, ni, 0);
if (mNotificationBuilder == null) {
mNotificationBuilder = new ProgressNotificationBuilder(getApplicationContext(),
R.layout.progress_notification,
getString(R.string.downloading_attachment),
R.drawable.ic_stat_notify,
pi);
}
// if we don't know the content length yet, start an interminate progress
foregroundNotification(totalBytes > 0 ? 0 : -1);
startForeground(NOTIFICATION_ID_DOWNLOADING, mCurrentNotification);
}
private void foregroundNotification(int progress) {
mCurrentNotification = mNotificationBuilder
.progress(progress,
R.string.attachment_download,
R.string.downloading_attachment)
.build();
}
public void stopForeground() {
stopForeground(true);
mCurrentNotification = null;
mTotalBytes = 0;
}
@Override
public void start(String url, File destination, long length) {
startForeground(length);
}
@Override
public void completed(String url, String mime, File destination) {
Uri uri = Uri.fromFile(destination);
ContentValues values = null;
// encrypted file?
if (mEncrypted) {
mCurrentNotification = mNotificationBuilder
.progress(-1,
R.string.attachment_download,
R.string.decrypting_attachment)
.build();
// send the updates to the notification manager
mNotificationManager.notify(NOTIFICATION_ID_DOWNLOADING, mCurrentNotification);
InputStream in = null;
OutputStream out = null;
try {
EndpointServer server = Preferences.getEndpointServer(this);
PersonalKey key = ((Kontalk) getApplicationContext()).getPersonalKey();
Coder coder = Keyring.getDecryptCoder(this, server, key, mPeer);
if (coder != null) {
in = new FileInputStream(destination);
File outFile = new File(destination + ".new");
out = new FileOutputStream(outFile);
List<DecryptException> errors = new LinkedList<>();
coder.decryptFile(in, true, out, errors);
// TODO process errors
// delete old file and rename the decrypted one
destination.delete();
outFile.renameTo(destination);
// save this for later
values = new ContentValues(3);
values.put(Messages.ATTACHMENT_ENCRYPTED, false);
values.put(Messages.ATTACHMENT_LENGTH, destination.length());
}
}
catch (Exception e) {
Log.e(TAG, "decryption failed!", e);
errorNotification(getString(R.string.notify_ticker_download_error),
getString(R.string.notify_text_decryption_error));
return;
}
finally {
try {
if (in != null)
in.close();
}
catch (IOException e) {
// ignored
}
try {
if (out != null)
out.close();
}
catch (IOException e) {
// ignored
}
}
}
// update messages.localUri
if (values == null)
values = new ContentValues(1);
values.put(Messages.ATTACHMENT_LOCAL_URI, uri.toString());
getContentResolver().update(ContentUris
.withAppendedId(Messages.CONTENT_URI, mMessageId), values, null, null);
// update media store
MediaStorage.scanFile(this, destination, mime);
// stop foreground
stopForeground();
// notify only if conversation is not open
if (!MessagingNotification.isPaused(mPeer) && mNotify) {
// detect mime type if not available
if (mime == null)
mime = getContentResolver().getType(uri);
// create intent for download complete notification
Intent i = new Intent(Intent.ACTION_VIEW);
i.setDataAndType(uri, mime);
i.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
PendingIntent pi = PendingIntent.getActivity(getApplicationContext(),
NOTIFICATION_ID_DOWNLOAD_OK, i, 0);
// create notification
NotificationCompat.Builder builder = new NotificationCompat.Builder(getApplicationContext())
.setSmallIcon(R.drawable.ic_stat_notify)
.setContentTitle(getString(R.string.notify_title_download_completed))
.setContentText(getString(R.string.notify_text_download_completed))
.setTicker(getString(R.string.notify_ticker_download_completed))
.setContentIntent(pi)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setAutoCancel(true);
// notify!!
mNotificationManager.notify(NOTIFICATION_ID_DOWNLOAD_OK, builder.build());
}
}
@Override
public void error(String url, File destination, Throwable exc) {
Log.e(TAG, "download error", exc);
stopForeground();
if (!mCanceled) {
ReportingManager.logException(exc);
errorNotification(getString(R.string.notify_ticker_download_error),
getString(R.string.notify_text_download_error));
}
}
private void errorNotification(String ticker, String text) {
// create intent for download error notification
Intent i = new Intent(this, ConversationsActivity.class);
i.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
PendingIntent pi = PendingIntent.getActivity(getApplicationContext(),
NOTIFICATION_ID_DOWNLOAD_ERROR, i, 0);
// create notification
NotificationCompat.Builder builder = new NotificationCompat.Builder(getApplicationContext())
.setSmallIcon(R.drawable.ic_stat_notify)
.setContentTitle(getString(R.string.notify_title_download_error))
.setContentText(text)
.setTicker(ticker)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setCategory(NotificationCompat.CATEGORY_ERROR)
.setContentIntent(pi)
.setAutoCancel(true);
// notify!!
mNotificationManager.notify(NOTIFICATION_ID_DOWNLOAD_ERROR, builder.build());
}
@Override
public void progress(String url, File destination, long bytes) {
if (mCurrentNotification != null) {
int progress = (int) ((100 * bytes) / mTotalBytes);
foregroundNotification(progress);
// send the updates to the notification manager
mNotificationManager.notify(NOTIFICATION_ID_DOWNLOADING, mCurrentNotification);
}
}
public static boolean isQueued(String url) {
return sQueue.containsKey(url);
}
public static void start(Context context, long databaseId, String sender,
String mime, long timestamp, boolean encrypted, String url) {
start(context, databaseId, sender, mime, timestamp, encrypted, url, true);
}
public static void start(Context context, long databaseId, String sender,
String mime, long timestamp, boolean encrypted, String url, boolean notify) {
Intent i = new Intent(context, DownloadService.class);
i.setAction(DownloadService.ACTION_DOWNLOAD_URL);
i.putExtra(CompositeMessage.MSG_ID, databaseId);
i.putExtra(CompositeMessage.MSG_SENDER, sender);
i.putExtra(CompositeMessage.MSG_MIME, mime);
i.putExtra(CompositeMessage.MSG_TIMESTAMP, timestamp);
i.putExtra(CompositeMessage.MSG_ENCRYPTED, encrypted);
i.putExtra(EXTRA_NOTIFY, notify);
i.setData(Uri.parse(url));
context.startService(i);
}
public static void abort(Context context, Uri uri) {
Intent i = new Intent(context, DownloadService.class);
i.setAction(DownloadService.ACTION_DOWNLOAD_ABORT);
i.setData(uri);
context.startService(i);
}
}