Implement Storage Access Framework

* re-work finished mission database
* re-work DownloadMission and bump it Serializable version
* keep the classic Java IO API
* SAF Tree API support on Android Lollipop or higher
* add wrapper for SAF stream opening
* implement Closeable in SharpStream to replace the dispose() method

* do required changes for this API:
** remove any file creation logic from DownloadInitializer
** make PostProcessing Serializable and reduce the number of iterations
** update all strings.xml files
** storage helpers: StoredDirectoryHelper & StoredFileHelper
** best effort to handle any kind of SAF errors/exceptions
This commit is contained in:
kapodamy 2019-04-05 14:45:39 -03:00
parent 8d8059229f
commit 7ca7952790
62 changed files with 2439 additions and 1180 deletions

View file

@ -3,10 +3,10 @@ package us.shandian.giga.get;
import android.support.annotation.NonNull;
import android.util.Log;
import java.io.File;
import org.schabi.newpipe.streams.io.SharpStream;
import java.io.IOException;
import java.io.InterruptedIOException;
import java.io.RandomAccessFile;
import java.net.HttpURLConnection;
import java.nio.channels.ClosedByInterruptException;
@ -111,34 +111,10 @@ public class DownloadInitializer extends Thread {
if (!mMission.running || Thread.interrupted()) return;
}
File file;
if (mMission.current == 0) {
file = new File(mMission.location);
if (!Utility.mkdir(file, true)) {
mMission.notifyError(DownloadMission.ERROR_PATH_CREATION, null);
return;
}
file = new File(file, mMission.name);
// if the name is used by another process, delete it
if (file.exists() && !file.isFile() && !file.delete()) {
mMission.notifyError(DownloadMission.ERROR_FILE_CREATION, null);
return;
}
if (!file.exists() && !file.createNewFile()) {
mMission.notifyError(DownloadMission.ERROR_FILE_CREATION, null);
return;
}
} else {
file = new File(mMission.location, mMission.name);
}
RandomAccessFile af = new RandomAccessFile(file, "rw");
af.setLength(mMission.offsets[mMission.current] + mMission.length);
af.seek(mMission.offsets[mMission.current]);
af.close();
SharpStream fs = mMission.storage.getStream();
fs.setLength(mMission.offsets[mMission.current] + mMission.length);
fs.seek(mMission.offsets[mMission.current]);
fs.close();
if (!mMission.running || Thread.interrupted()) return;

View file

@ -2,6 +2,7 @@ package us.shandian.giga.get;
import android.os.Handler;
import android.os.Message;
import android.support.annotation.NonNull;
import android.util.Log;
import java.io.File;
@ -17,6 +18,7 @@ import java.util.List;
import javax.net.ssl.SSLException;
import us.shandian.giga.io.StoredFileHelper;
import us.shandian.giga.postprocessing.Postprocessing;
import us.shandian.giga.service.DownloadManagerService;
import us.shandian.giga.util.Utility;
@ -24,7 +26,7 @@ import us.shandian.giga.util.Utility;
import static org.schabi.newpipe.BuildConfig.DEBUG;
public class DownloadMission extends Mission {
private static final long serialVersionUID = 3L;// last bump: 8 november 2018
private static final long serialVersionUID = 4L;// last bump: 27 march 2019
static final int BUFFER_SIZE = 64 * 1024;
final static int BLOCK_SIZE = 512 * 1024;
@ -43,6 +45,7 @@ public class DownloadMission extends Mission {
public static final int ERROR_POSTPROCESSING_STOPPED = 1008;
public static final int ERROR_POSTPROCESSING_HOLD = 1009;
public static final int ERROR_INSUFFICIENT_STORAGE = 1010;
public static final int ERROR_PROGRESS_LOST = 1011;
public static final int ERROR_HTTP_NO_CONTENT = 204;
public static final int ERROR_HTTP_UNSUPPORTED_RANGE = 206;
@ -71,16 +74,6 @@ public class DownloadMission extends Mission {
*/
public long[] offsets;
/**
* The post-processing algorithm arguments
*/
public String[] postprocessingArgs;
/**
* The post-processing algorithm name
*/
public String postprocessingName;
/**
* Indicates if the post-processing state:
* 0: ready
@ -88,12 +81,12 @@ public class DownloadMission extends Mission {
* 2: completed
* 3: hold
*/
public volatile int postprocessingState;
public volatile int psState;
/**
* Indicate if the post-processing algorithm works on the same file
* the post-processing algorithm instance
*/
public boolean postprocessingThis;
public transient Postprocessing psAlgorithm;
/**
* The current resource to download, see {@code urls[current]} and {@code offsets[current]}
@ -138,36 +131,23 @@ public class DownloadMission extends Mission {
public transient volatile Thread[] threads = new Thread[0];
private transient Thread init = null;
protected DownloadMission() {
}
public DownloadMission(String url, String name, String location, char kind) {
this(new String[]{url}, name, location, kind, null, null);
}
public DownloadMission(String[] urls, String name, String location, char kind, String postprocessingName, String[] postprocessingArgs) {
if (name == null) throw new NullPointerException("name is null");
if (name.isEmpty()) throw new IllegalArgumentException("name is empty");
public DownloadMission(String[] urls, StoredFileHelper storage, char kind, Postprocessing psInstance) {
if (urls == null) throw new NullPointerException("urls is null");
if (urls.length < 1) throw new IllegalArgumentException("urls is empty");
if (location == null) throw new NullPointerException("location is null");
if (location.isEmpty()) throw new IllegalArgumentException("location is empty");
this.urls = urls;
this.name = name;
this.location = location;
this.kind = kind;
this.offsets = new long[urls.length];
this.enqueued = true;
this.maxRetry = 3;
this.storage = storage;
if (postprocessingName != null) {
Postprocessing algorithm = Postprocessing.getAlgorithm(postprocessingName, null);
this.postprocessingThis = algorithm.worksOnSameFile;
this.offsets[0] = algorithm.recommendedReserve;
this.postprocessingName = postprocessingName;
this.postprocessingArgs = postprocessingArgs;
if (psInstance != null) {
this.psAlgorithm = psInstance;
this.offsets[0] = psInstance.recommendedReserve;
} else {
if (DEBUG && urls.length > 1) {
Log.w(TAG, "mission created with multiple urls ¿missing post-processing algorithm?");
@ -359,22 +339,12 @@ public class DownloadMission extends Mission {
Log.e(TAG, "notifyError() code = " + code, err);
if (err instanceof IOException) {
if (err.getMessage().contains("Permission denied")) {
if (storage.canWrite() || err.getMessage().contains("Permission denied")) {
code = ERROR_PERMISSION_DENIED;
err = null;
} else if (err.getMessage().contains("write failed: ENOSPC")) {
} else if (err.getMessage().contains("ENOSPC")) {
code = ERROR_INSUFFICIENT_STORAGE;
err = null;
} else {
try {
File storage = new File(location);
if (storage.canWrite() && storage.getUsableSpace() < (getLength() - done)) {
code = ERROR_INSUFFICIENT_STORAGE;
err = null;
}
} catch (SecurityException e) {
// is a permission error
}
}
}
@ -433,11 +403,11 @@ public class DownloadMission extends Mission {
action = "Failed";
}
Log.d(TAG, action + " postprocessing on " + location + File.separator + name);
Log.d(TAG, action + " postprocessing on " + storage.getName());
synchronized (blockState) {
// don't return without fully write the current state
postprocessingState = state;
psState = state;
Utility.writeToFile(metadata, DownloadMission.this);
}
}
@ -456,7 +426,7 @@ public class DownloadMission extends Mission {
running = true;
errCode = ERROR_NOTHING;
if (current >= urls.length && postprocessingName != null) {
if (current >= urls.length && psAlgorithm != null) {
runAsync(1, () -> {
if (doPostprocessing()) {
running = false;
@ -593,7 +563,7 @@ public class DownloadMission extends Mission {
* @return true, otherwise, false
*/
public boolean isFinished() {
return current >= urls.length && (postprocessingName == null || postprocessingState == 2);
return current >= urls.length && (psAlgorithm == null || psState == 2);
}
/**
@ -602,7 +572,13 @@ public class DownloadMission extends Mission {
* @return {@code true} if this mission is unrecoverable
*/
public boolean isPsFailed() {
return postprocessingName != null && errCode == DownloadMission.ERROR_POSTPROCESSING && postprocessingThis;
switch (errCode) {
case ERROR_POSTPROCESSING:
case ERROR_POSTPROCESSING_STOPPED:
return psAlgorithm.worksOnSameFile;
}
return false;
}
/**
@ -611,7 +587,7 @@ public class DownloadMission extends Mission {
* @return true, otherwise, false
*/
public boolean isPsRunning() {
return postprocessingName != null && (postprocessingState == 1 || postprocessingState == 3);
return psAlgorithm != null && (psState == 1 || psState == 3);
}
/**
@ -625,7 +601,7 @@ public class DownloadMission extends Mission {
public long getLength() {
long calculated;
if (postprocessingState == 1 || postprocessingState == 3) {
if (psState == 1 || psState == 3) {
calculated = length;
} else {
calculated = offsets[current < offsets.length ? current : (offsets.length - 1)] + length;
@ -652,38 +628,60 @@ public class DownloadMission extends Mission {
* @param recover {@code true} to retry, otherwise, {@code false} to cancel
*/
public void psContinue(boolean recover) {
postprocessingState = 1;
psState = 1;
errCode = recover ? ERROR_NOTHING : ERROR_POSTPROCESSING;
threads[0].interrupt();
}
/**
* changes the StoredFileHelper for another and saves the changes to the metadata file
*
* @param newStorage the new StoredFileHelper instance to use
*/
public void changeStorage(@NonNull StoredFileHelper newStorage) {
storage = newStorage;
// commit changes on the metadata file
runAsync(-2, this::writeThisToFile);
}
/**
* Indicates whatever the backed storage is invalid
*
* @return {@code true}, if storage is invalid and cannot be used
*/
public boolean hasInvalidStorage() {
return errCode == ERROR_PROGRESS_LOST || storage == null || storage.isInvalid();
}
/**
* Indicates whatever is possible to start the mission
*
* @return {@code true} is this mission is "sane", otherwise, {@code false}
*/
public boolean canDownload() {
return !(isPsFailed() || errCode == ERROR_POSTPROCESSING_HOLD) && !isFinished() && !hasInvalidStorage();
}
private boolean doPostprocessing() {
if (postprocessingName == null || postprocessingState == 2) return true;
if (psAlgorithm == null || psState == 2) return true;
notifyPostProcessing(1);
notifyProgress(0);
if (DEBUG)
Thread.currentThread().setName("[" + TAG + "] post-processing = " + postprocessingName + " filename = " + name);
Thread.currentThread().setName("[" + TAG + "] ps = " +
psAlgorithm.getClass().getSimpleName() +
" filename = " + storage.getName()
);
threads = new Thread[]{Thread.currentThread()};
Exception exception = null;
try {
Postprocessing
.getAlgorithm(postprocessingName, this)
.run();
psAlgorithm.run(this);
} catch (Exception err) {
StringBuilder args = new StringBuilder(" ");
if (postprocessingArgs != null) {
for (String arg : postprocessingArgs) {
args.append(", ");
args.append(arg);
}
args.delete(0, 1);
}
Log.e(TAG, String.format("Post-processing failed. algorithm = %s args = [%s]", postprocessingName, args), err);
Log.e(TAG, "Post-processing failed. " + psAlgorithm.toString(), err);
if (errCode == ERROR_NOTHING) errCode = ERROR_POSTPROCESSING;
@ -733,7 +731,7 @@ public class DownloadMission extends Mission {
// >=1: any download thread
if (DEBUG) {
who.setName(String.format("%s[%s] %s", TAG, id, name));
who.setName(String.format("%s[%s] %s", TAG, id, storage.getName()));
}
who.start();

View file

@ -2,9 +2,10 @@ package us.shandian.giga.get;
import android.util.Log;
import java.io.FileNotFoundException;
import org.schabi.newpipe.streams.io.SharpStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.RandomAccessFile;
import java.net.HttpURLConnection;
import java.nio.channels.ClosedByInterruptException;
@ -40,12 +41,12 @@ public class DownloadRunnable extends Thread {
Log.d(TAG, mId + ":recovered: " + mMission.recovered);
}
RandomAccessFile f;
SharpStream f;
InputStream is = null;
try {
f = new RandomAccessFile(mMission.getDownloadedFile(), "rw");
} catch (FileNotFoundException e) {
f = mMission.storage.getStream();
} catch (IOException e) {
mMission.notifyError(e);// this never should happen
return;
}

View file

@ -4,13 +4,13 @@ import android.annotation.SuppressLint;
import android.support.annotation.NonNull;
import android.util.Log;
import org.schabi.newpipe.streams.io.SharpStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.RandomAccessFile;
import java.net.HttpURLConnection;
import java.nio.channels.ClosedByInterruptException;
import us.shandian.giga.util.Utility;
import static org.schabi.newpipe.BuildConfig.DEBUG;
@ -22,11 +22,10 @@ public class DownloadRunnableFallback extends Thread {
private static final String TAG = "DownloadRunnableFallback";
private final DownloadMission mMission;
private final int mId = 1;
private int mRetryCount = 0;
private InputStream mIs;
private RandomAccessFile mF;
private SharpStream mF;
private HttpURLConnection mConn;
DownloadRunnableFallback(@NonNull DownloadMission mission) {
@ -43,11 +42,7 @@ public class DownloadRunnableFallback extends Thread {
// nothing to do
}
try {
if (mF != null) mF.close();
} catch (IOException e) {
// ¿ejected media storage? ¿file deleted? ¿storage ran out of space?
}
if (mF != null) mF.close();
}
@Override
@ -67,6 +62,7 @@ public class DownloadRunnableFallback extends Thread {
try {
long rangeStart = (mMission.unknownLength || start < 1) ? -1 : start;
int mId = 1;
mConn = mMission.openConnection(mId, rangeStart, -1);
mMission.establishConnection(mId, mConn);
@ -81,7 +77,7 @@ public class DownloadRunnableFallback extends Thread {
if (!mMission.unknownLength)
mMission.unknownLength = Utility.getContentLength(mConn) == -1;
mF = new RandomAccessFile(mMission.getDownloadedFile(), "rw");
mF = mMission.storage.getStream();
mF.seek(mMission.offsets[mMission.current] + start);
mIs = mConn.getInputStream();

View file

@ -1,16 +1,16 @@
package us.shandian.giga.get;
import android.support.annotation.NonNull;
public class FinishedMission extends Mission {
public FinishedMission() {
}
public FinishedMission(DownloadMission mission) {
public FinishedMission(@NonNull DownloadMission mission) {
source = mission.source;
length = mission.length;// ¿or mission.done?
timestamp = mission.timestamp;
name = mission.name;
location = mission.location;
kind = mission.kind;
}
}

View file

@ -1,12 +1,15 @@
package us.shandian.giga.get;
import java.io.File;
import android.net.Uri;
import android.support.annotation.NonNull;
import java.io.Serializable;
import java.text.SimpleDateFormat;
import java.util.Calendar;
import us.shandian.giga.io.StoredFileHelper;
public abstract class Mission implements Serializable {
private static final long serialVersionUID = 0L;// last bump: 5 october 2018
private static final long serialVersionUID = 1L;// last bump: 27 march 2019
/**
* Source url of the resource
@ -23,28 +26,23 @@ public abstract class Mission implements Serializable {
*/
public long timestamp;
/**
* The filename
*/
public String name;
/**
* The directory to store the download
*/
public String location;
/**
* pre-defined content type
*/
public char kind;
/**
* The downloaded file
*/
public StoredFileHelper storage;
/**
* get the target file on the storage
*
* @return File object
*/
public File getDownloadedFile() {
return new File(location, name);
public Uri getDownloadedFileUri() {
return storage.getUri();
}
/**
@ -53,8 +51,8 @@ public abstract class Mission implements Serializable {
* @return {@code true] if and only if the file is successfully deleted, otherwise, {@code false}
*/
public boolean delete() {
deleted = true;
return getDownloadedFile().delete();
if (storage != null) return storage.delete();
return true;
}
/**
@ -62,10 +60,11 @@ public abstract class Mission implements Serializable {
*/
public transient boolean deleted = false;
@NonNull
@Override
public String toString() {
Calendar calendar = Calendar.getInstance();
calendar.setTimeInMillis(timestamp);
return "[" + calendar.getTime().toString() + "] " + location + File.separator + name;
return "[" + calendar.getTime().toString() + "] " + getDownloadedFileUri().getPath();
}
}

View file

@ -1,73 +0,0 @@
package us.shandian.giga.get.sqlite;
import android.content.ContentValues;
import android.content.Context;
import android.database.Cursor;
import android.database.sqlite.SQLiteDatabase;
import android.util.Log;
import java.util.ArrayList;
import java.util.List;
import us.shandian.giga.get.DownloadMission;
import us.shandian.giga.get.FinishedMission;
import us.shandian.giga.get.Mission;
import static us.shandian.giga.get.sqlite.DownloadMissionHelper.KEY_LOCATION;
import static us.shandian.giga.get.sqlite.DownloadMissionHelper.KEY_NAME;
import static us.shandian.giga.get.sqlite.DownloadMissionHelper.MISSIONS_TABLE_NAME;
public class DownloadDataSource {
private static final String TAG = "DownloadDataSource";
private final DownloadMissionHelper downloadMissionHelper;
public DownloadDataSource(Context context) {
downloadMissionHelper = new DownloadMissionHelper(context);
}
public ArrayList<FinishedMission> loadFinishedMissions() {
SQLiteDatabase database = downloadMissionHelper.getReadableDatabase();
Cursor cursor = database.query(MISSIONS_TABLE_NAME, null, null,
null, null, null, DownloadMissionHelper.KEY_TIMESTAMP);
int count = cursor.getCount();
if (count == 0) return new ArrayList<>(1);
ArrayList<FinishedMission> result = new ArrayList<>(count);
while (cursor.moveToNext()) {
result.add(DownloadMissionHelper.getMissionFromCursor(cursor));
}
return result;
}
public void addMission(DownloadMission downloadMission) {
if (downloadMission == null) throw new NullPointerException("downloadMission is null");
SQLiteDatabase database = downloadMissionHelper.getWritableDatabase();
ContentValues values = DownloadMissionHelper.getValuesOfMission(downloadMission);
database.insert(MISSIONS_TABLE_NAME, null, values);
}
public void deleteMission(Mission downloadMission) {
if (downloadMission == null) throw new NullPointerException("downloadMission is null");
SQLiteDatabase database = downloadMissionHelper.getWritableDatabase();
database.delete(MISSIONS_TABLE_NAME,
KEY_LOCATION + " = ? AND " +
KEY_NAME + " = ?",
new String[]{downloadMission.location, downloadMission.name});
}
public void updateMission(DownloadMission downloadMission) {
if (downloadMission == null) throw new NullPointerException("downloadMission is null");
SQLiteDatabase database = downloadMissionHelper.getWritableDatabase();
ContentValues values = DownloadMissionHelper.getValuesOfMission(downloadMission);
String whereClause = KEY_LOCATION + " = ? AND " +
KEY_NAME + " = ?";
int rowsAffected = database.update(MISSIONS_TABLE_NAME, values,
whereClause, new String[]{downloadMission.location, downloadMission.name});
if (rowsAffected != 1) {
Log.e(TAG, "Expected 1 row to be affected by update but got " + rowsAffected);
}
}
}

View file

@ -1,112 +0,0 @@
package us.shandian.giga.get.sqlite;
import android.content.ContentValues;
import android.content.Context;
import android.database.Cursor;
import android.database.sqlite.SQLiteDatabase;
import android.database.sqlite.SQLiteOpenHelper;
import us.shandian.giga.get.DownloadMission;
import us.shandian.giga.get.FinishedMission;
/**
* SQLiteHelper to store finished {@link us.shandian.giga.get.DownloadMission}'s
*/
public class DownloadMissionHelper extends SQLiteOpenHelper {
private final String TAG = "DownloadMissionHelper";
// TODO: use NewPipeSQLiteHelper ('s constants) when playlist branch is merged (?)
private static final String DATABASE_NAME = "downloads.db";
private static final int DATABASE_VERSION = 3;
/**
* The table name of download missions
*/
static final String MISSIONS_TABLE_NAME = "download_missions";
/**
* The key to the directory location of the mission
*/
static final String KEY_LOCATION = "location";
/**
* The key to the urls of a mission
*/
static final String KEY_SOURCE_URL = "url";
/**
* The key to the name of a mission
*/
static final String KEY_NAME = "name";
/**
* The key to the done.
*/
static final String KEY_DONE = "bytes_downloaded";
static final String KEY_TIMESTAMP = "timestamp";
static final String KEY_KIND = "kind";
/**
* The statement to create the table
*/
private static final String MISSIONS_CREATE_TABLE =
"CREATE TABLE " + MISSIONS_TABLE_NAME + " (" +
KEY_LOCATION + " TEXT NOT NULL, " +
KEY_NAME + " TEXT NOT NULL, " +
KEY_SOURCE_URL + " TEXT NOT NULL, " +
KEY_DONE + " INTEGER NOT NULL, " +
KEY_TIMESTAMP + " INTEGER NOT NULL, " +
KEY_KIND + " TEXT NOT NULL, " +
" UNIQUE(" + KEY_LOCATION + ", " + KEY_NAME + "));";
public DownloadMissionHelper(Context context) {
super(context, DATABASE_NAME, null, DATABASE_VERSION);
}
@Override
public void onCreate(SQLiteDatabase db) {
db.execSQL(MISSIONS_CREATE_TABLE);
}
@Override
public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
if (oldVersion == 2) {
db.execSQL("ALTER TABLE " + MISSIONS_TABLE_NAME + " ADD COLUMN " + KEY_KIND + " TEXT;");
}
}
/**
* Returns all values of the download mission as ContentValues.
*
* @param downloadMission the download mission
* @return the content values
*/
public static ContentValues getValuesOfMission(DownloadMission downloadMission) {
ContentValues values = new ContentValues();
values.put(KEY_SOURCE_URL, downloadMission.source);
values.put(KEY_LOCATION, downloadMission.location);
values.put(KEY_NAME, downloadMission.name);
values.put(KEY_DONE, downloadMission.done);
values.put(KEY_TIMESTAMP, downloadMission.timestamp);
values.put(KEY_KIND, String.valueOf(downloadMission.kind));
return values;
}
public static FinishedMission getMissionFromCursor(Cursor cursor) {
if (cursor == null) throw new NullPointerException("cursor is null");
String kind = cursor.getString(cursor.getColumnIndex(KEY_KIND));
if (kind == null || kind.isEmpty()) kind = "?";
FinishedMission mission = new FinishedMission();
mission.name = cursor.getString(cursor.getColumnIndexOrThrow(KEY_NAME));
mission.location = cursor.getString(cursor.getColumnIndexOrThrow(KEY_LOCATION));
mission.source = cursor.getString(cursor.getColumnIndexOrThrow(KEY_SOURCE_URL));;
mission.length = cursor.getLong(cursor.getColumnIndexOrThrow(KEY_DONE));
mission.timestamp = cursor.getLong(cursor.getColumnIndexOrThrow(KEY_TIMESTAMP));
mission.kind = kind.charAt(0);
return mission;
}
}

View file

@ -0,0 +1,223 @@
package us.shandian.giga.get.sqlite;
import android.content.ContentValues;
import android.content.Context;
import android.database.Cursor;
import android.database.sqlite.SQLiteDatabase;
import android.database.sqlite.SQLiteOpenHelper;
import android.net.Uri;
import android.support.annotation.NonNull;
import android.util.Log;
import java.io.File;
import java.util.ArrayList;
import us.shandian.giga.get.DownloadMission;
import us.shandian.giga.get.FinishedMission;
import us.shandian.giga.get.Mission;
import us.shandian.giga.io.StoredFileHelper;
/**
* SQLite helper to store finished {@link us.shandian.giga.get.FinishedMission}'s
*/
public class FinishedMissionStore extends SQLiteOpenHelper {
// TODO: use NewPipeSQLiteHelper ('s constants) when playlist branch is merged (?)
private static final String DATABASE_NAME = "downloads.db";
private static final int DATABASE_VERSION = 4;
/**
* The table name of download missions (old)
*/
private static final String MISSIONS_TABLE_NAME_v2 = "download_missions";
/**
* The table name of download missions
*/
private static final String FINISHED_MISSIONS_TABLE_NAME = "finished_missions";
/**
* The key to the urls of a mission
*/
private static final String KEY_SOURCE = "url";
/**
* The key to the done.
*/
private static final String KEY_DONE = "bytes_downloaded";
private static final String KEY_TIMESTAMP = "timestamp";
private static final String KEY_KIND = "kind";
private static final String KEY_PATH = "path";
/**
* The statement to create the table
*/
private static final String MISSIONS_CREATE_TABLE =
"CREATE TABLE " + FINISHED_MISSIONS_TABLE_NAME + " (" +
KEY_PATH + " TEXT NOT NULL, " +
KEY_SOURCE + " TEXT NOT NULL, " +
KEY_DONE + " INTEGER NOT NULL, " +
KEY_TIMESTAMP + " INTEGER NOT NULL, " +
KEY_KIND + " TEXT NOT NULL, " +
" UNIQUE(" + KEY_TIMESTAMP + ", " + KEY_PATH + "));";
private Context context;
public FinishedMissionStore(Context context) {
super(context, DATABASE_NAME, null, DATABASE_VERSION);
this.context = context;
}
@Override
public void onCreate(SQLiteDatabase db) {
db.execSQL(MISSIONS_CREATE_TABLE);
}
@Override
public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
if (oldVersion == 2) {
db.execSQL("ALTER TABLE " + MISSIONS_TABLE_NAME_v2 + " ADD COLUMN " + KEY_KIND + " TEXT;");
oldVersion++;
}
if (oldVersion == 3) {
final String KEY_LOCATION = "location";
final String KEY_NAME = "name";
db.execSQL(MISSIONS_CREATE_TABLE);
Cursor cursor = db.query(MISSIONS_TABLE_NAME_v2, null, null,
null, null, null, KEY_TIMESTAMP);
int count = cursor.getCount();
if (count > 0) {
db.beginTransaction();
while (cursor.moveToNext()) {
ContentValues values = new ContentValues();
values.put(KEY_SOURCE, cursor.getString(cursor.getColumnIndex(KEY_SOURCE)));
values.put(KEY_DONE, cursor.getString(cursor.getColumnIndex(KEY_DONE)));
values.put(KEY_TIMESTAMP, cursor.getLong(cursor.getColumnIndex(KEY_TIMESTAMP)));
values.put(KEY_KIND, cursor.getString(cursor.getColumnIndex(KEY_KIND)));
values.put(KEY_PATH, Uri.fromFile(
new File(
cursor.getString(cursor.getColumnIndex(KEY_LOCATION)),
cursor.getString(cursor.getColumnIndex(KEY_NAME))
)
).toString());
db.insert(FINISHED_MISSIONS_TABLE_NAME, null, values);
}
db.setTransactionSuccessful();
db.endTransaction();
}
cursor.close();
db.execSQL("DROP TABLE " + MISSIONS_TABLE_NAME_v2);
}
}
/**
* Returns all values of the download mission as ContentValues.
*
* @param downloadMission the download mission
* @return the content values
*/
private ContentValues getValuesOfMission(@NonNull Mission downloadMission) {
ContentValues values = new ContentValues();
values.put(KEY_SOURCE, downloadMission.source);
values.put(KEY_PATH, downloadMission.storage.getUri().toString());
values.put(KEY_DONE, downloadMission.length);
values.put(KEY_TIMESTAMP, downloadMission.timestamp);
values.put(KEY_KIND, String.valueOf(downloadMission.kind));
return values;
}
private FinishedMission getMissionFromCursor(Cursor cursor) {
if (cursor == null) throw new NullPointerException("cursor is null");
String kind = cursor.getString(cursor.getColumnIndex(KEY_KIND));
if (kind == null || kind.isEmpty()) kind = "?";
String path = cursor.getString(cursor.getColumnIndexOrThrow(KEY_PATH));
FinishedMission mission = new FinishedMission();
mission.source = cursor.getString(cursor.getColumnIndexOrThrow(KEY_SOURCE));
mission.length = cursor.getLong(cursor.getColumnIndexOrThrow(KEY_DONE));
mission.timestamp = cursor.getLong(cursor.getColumnIndexOrThrow(KEY_TIMESTAMP));
mission.kind = kind.charAt(0);
try {
mission.storage = new StoredFileHelper(context, Uri.parse(path), "");
} catch (Exception e) {
Log.e("FinishedMissionStore", "failed to load the storage path of: " + path, e);
mission.storage = new StoredFileHelper(path, "", "");
}
return mission;
}
//////////////////////////////////
// Data source methods
///////////////////////////////////
public ArrayList<FinishedMission> loadFinishedMissions() {
SQLiteDatabase database = getReadableDatabase();
Cursor cursor = database.query(FINISHED_MISSIONS_TABLE_NAME, null, null,
null, null, null, KEY_TIMESTAMP + " DESC");
int count = cursor.getCount();
if (count == 0) return new ArrayList<>(1);
ArrayList<FinishedMission> result = new ArrayList<>(count);
while (cursor.moveToNext()) {
result.add(getMissionFromCursor(cursor));
}
return result;
}
public void addFinishedMission(DownloadMission downloadMission) {
if (downloadMission == null) throw new NullPointerException("downloadMission is null");
SQLiteDatabase database = getWritableDatabase();
ContentValues values = getValuesOfMission(downloadMission);
database.insert(FINISHED_MISSIONS_TABLE_NAME, null, values);
}
public void deleteMission(Mission mission) {
if (mission == null) throw new NullPointerException("mission is null");
String path = mission.getDownloadedFileUri().toString();
SQLiteDatabase database = getWritableDatabase();
if (mission instanceof FinishedMission)
database.delete(FINISHED_MISSIONS_TABLE_NAME, KEY_TIMESTAMP + " = ?, " + KEY_PATH + " = ?", new String[]{path});
else
throw new UnsupportedOperationException("DownloadMission");
}
public void updateMission(Mission mission) {
if (mission == null) throw new NullPointerException("mission is null");
SQLiteDatabase database = getWritableDatabase();
ContentValues values = getValuesOfMission(mission);
String path = mission.getDownloadedFileUri().toString();
int rowsAffected;
if (mission instanceof FinishedMission)
rowsAffected = database.update(FINISHED_MISSIONS_TABLE_NAME, values, KEY_PATH + " = ?", new String[]{path});
else
throw new UnsupportedOperationException("DownloadMission");
if (rowsAffected != 1) {
Log.e("FinishedMissionStore", "Expected 1 row to be affected by update but got " + rowsAffected);
}
}
}