/***************************************************************************************
* Copyright (c) 2011 Norbert Nagold <norbert.nagold@gmail.com> *
* *
* 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 com.ichi2.anki;
import android.content.SharedPreferences;
import android.os.StatFs;
import com.ichi2.libanki.Collection;
import com.ichi2.libanki.Utils;
import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.Collections;
import java.util.Date;
import java.util.GregorianCalendar;
import java.util.Locale;
import java.util.UnknownFormatConversionException;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import timber.log.Timber;
public class BackupManager {
public static final int MIN_FREE_SPACE = 10;
public static final int MIN_BACKUP_COL_SIZE = 10000; // threshold in bytes to backup a col file
public final static String BACKUP_SUFFIX = "backup";
public final static String BROKEN_DECKS_SUFFIX = "broken";
private static boolean mUseBackups = true;
/** Number of hours after which a backup new backup is created */
public static final int BACKUP_INTERVAL = 5;
/* Prevent class from being instantiated */
private BackupManager() {
}
public static boolean isActivated() {
return mUseBackups;
}
private static File getBackupDirectory(File ankidroidDir) {
File directory = new File(ankidroidDir, BACKUP_SUFFIX);
if (!directory.isDirectory()) {
directory.mkdirs();
}
return directory;
}
private static File getBrokenDirectory(File ankidroidDir) {
File directory = new File(ankidroidDir, BROKEN_DECKS_SUFFIX);
if (!directory.isDirectory()) {
directory.mkdirs();
}
return directory;
}
public static boolean performBackupInBackground(String path) {
return performBackupInBackground(path, BACKUP_INTERVAL, false);
}
public static boolean performBackupInBackground(String path, boolean force) {
return performBackupInBackground(path, BACKUP_INTERVAL, force);
}
public static boolean performBackupInBackground(String path, int interval) {
return performBackupInBackground(path, interval, false);
}
public static boolean performBackupInBackground(final String colPath, int interval, boolean force) {
SharedPreferences prefs = AnkiDroidApp.getSharedPrefs(AnkiDroidApp.getInstance().getBaseContext());
if (prefs.getInt("backupMax", 8) == 0 && !force) {
Timber.w("backups are disabled");
return false;
}
final File colFile = new File(colPath);
File[] deckBackups = getBackups(colFile);
int len = deckBackups.length;
if (len > 0 && deckBackups[len - 1].lastModified() == colFile.lastModified()) {
Timber.d("performBackup: No backup necessary due to no collection changes");
return false;
}
SimpleDateFormat df = new SimpleDateFormat("yyyy-MM-dd-HH-mm", Locale.US);
Calendar cal = new GregorianCalendar();
cal.setTimeInMillis(System.currentTimeMillis());
// Abort backup if one was already made less than 5 hours ago
Date lastBackupDate = null;
while (lastBackupDate == null && len > 0) {
try {
len--;
lastBackupDate = df.parse(deckBackups[len].getName().replaceAll(
"^.*-(\\d{4}-\\d{2}-\\d{2}-\\d{2}-\\d{2}).apkg$", "$1"));
} catch (ParseException e) {
lastBackupDate = null;
}
}
if (lastBackupDate != null && lastBackupDate.getTime() + interval * 3600000L > Utils.intNow(1000) && !force) {
Timber.d("performBackup: No backup created. Last backup younger than 5 hours");
return false;
}
String backupFilename;
try {
backupFilename = String.format(Utils.ENGLISH_LOCALE, colFile.getName().replace(".anki2", "")
+ "-%s.apkg", df.format(cal.getTime()));
} catch (UnknownFormatConversionException e) {
Timber.e(e, "performBackup: error on creating backup filename");
return false;
}
// Abort backup if destination already exists (extremely unlikely)
final File backupFile = new File(getBackupDirectory(colFile.getParentFile()), backupFilename);
if (backupFile.exists()) {
Timber.d("performBackup: No new backup created. File already exists");
return false;
}
// Abort backup if not enough free space
if (getFreeDiscSpace(colFile) < colFile.length() + (MIN_FREE_SPACE * 1024 * 1024)) {
Timber.e("performBackup: Not enough space on sd card to backup.");
prefs.edit().putBoolean("noSpaceLeft", true).commit();
return false;
}
// Don't bother trying to do backup if the collection is too small to be valid
if (colFile.length() < MIN_BACKUP_COL_SIZE) {
Timber.d("performBackup: No backup created as the collection is too small to be valid");
return false;
}
// TODO: Probably not a good idea to do the backup while the collection is open
if (CollectionHelper.getInstance().colIsOpen()) {
Timber.w("Collection is already open during backup... we probably shouldn't be doing this");
}
Timber.i("Launching new thread to backup %s to %s", colPath, backupFile.getPath());
// Backup collection as apkg in new thread
Thread thread = new Thread() {
@Override
public void run() {
// Save collection file as zip archive
int BUFFER_SIZE = 1024;
byte[] buf = new byte[BUFFER_SIZE];
try {
BufferedInputStream bis = new BufferedInputStream(new FileInputStream(colPath), BUFFER_SIZE);
ZipOutputStream zos = new ZipOutputStream(new BufferedOutputStream(new FileOutputStream(backupFile)));
ZipEntry ze = new ZipEntry("collection.anki2");
zos.putNextEntry(ze);
int len;
while ((len = bis.read(buf, 0, BUFFER_SIZE)) != -1) {
zos.write(buf, 0, len);
}
zos.close();
bis.close();
// Delete old backup files if needed
SharedPreferences prefs = AnkiDroidApp.getSharedPrefs(AnkiDroidApp.getInstance().getBaseContext());
deleteDeckBackups(colPath, prefs.getInt("backupMax", 8));
// set timestamp of file in order to avoid creating a new backup unless its changed
backupFile.setLastModified(colFile.lastModified());
Timber.i("Backup created succesfully");
} catch (IOException e) {
e.printStackTrace();
}
}
};
thread.start();
return true;
}
public static boolean enoughDiscSpace(String path) {
return getFreeDiscSpace(path) >= (MIN_FREE_SPACE * 1024 * 1024);
}
/**
* Get free disc space in bytes from path to Collection
* @param path
* @return
*/
public static long getFreeDiscSpace(String path) {
return getFreeDiscSpace(new File(path));
}
private static long getFreeDiscSpace(File file) {
try {
StatFs stat = new StatFs(file.getParentFile().getPath());
long blocks = stat.getAvailableBlocks();
long blocksize = stat.getBlockSize();
return blocks * blocksize;
} catch (IllegalArgumentException e) {
Timber.e(e, "Free space could not be retrieved");
return MIN_FREE_SPACE * 1024 * 1024;
}
}
/**
* Run the sqlite3 command-line-tool (if it exists) on the collection to dump to a text file
* and reload as a new database. Recently this command line tool isn't available on many devices
*
* @param col Collection
* @return whether the repair was successful
*/
public static boolean repairCollection(Collection col) {
String deckPath = col.getPath();
File deckFile = new File(deckPath);
if (col != null) {
col.close();
}
// repair file
String execString = "sqlite3 " + deckPath + " .dump | sqlite3 " + deckPath + ".tmp";
Timber.i("repairCollection - Execute: " + execString);
try {
String[] cmd = { "/system/bin/sh", "-c", execString };
Process process = Runtime.getRuntime().exec(cmd);
process.waitFor();
if (!new File(deckPath + ".tmp").exists()) {
Timber.e("repairCollection - dump to " + deckPath + ".tmp failed");
return false;
}
if (!moveDatabaseToBrokenFolder(deckPath, false)) {
Timber.e("repairCollection - could not move corrupt file to broken folder");
return false;
}
Timber.i("repairCollection - moved corrupt file to broken folder");
File repairedFile = new File(deckPath + ".tmp");
return repairedFile.renameTo(deckFile);
} catch (IOException | InterruptedException e) {
Timber.e("repairCollection - error: " + e.getMessage());
}
return false;
}
public static boolean moveDatabaseToBrokenFolder(String colPath, boolean moveConnectedFilesToo) {
File colFile = new File(colPath);
// move file
Date value = Utils.genToday(Utils.utcOffset());
String movedFilename = String.format(Utils.ENGLISH_LOCALE, colFile.getName().replace(".anki2", "")
+ "-corrupt-%tF.anki2", value);
File movedFile = new File(getBrokenDirectory(colFile.getParentFile()), movedFilename);
int i = 1;
while (movedFile.exists()) {
movedFile = new File(getBrokenDirectory(colFile.getParentFile()), movedFilename.replace(".anki2",
"-" + Integer.toString(i) + ".anki2"));
i++;
}
movedFilename = movedFile.getName();
if (!colFile.renameTo(movedFile)) {
return false;
}
if (moveConnectedFilesToo) {
// move all connected files (like journals, directories...) too
String deckName = colFile.getName();
File directory = new File(colFile.getParent());
for (File f : directory.listFiles()) {
if (f.getName().startsWith(deckName)) {
if (!f.renameTo(new File(getBrokenDirectory(colFile.getParentFile()), f.getName().replace(deckName,
movedFilename)))) {
return false;
}
}
}
}
return true;
}
public static File[] getBackups(File colFile) {
File[] files = getBackupDirectory(colFile.getParentFile()).listFiles();
if (files == null) {
files = new File[0];
}
ArrayList<File> deckBackups = new ArrayList<>();
for (File aktFile : files) {
if (aktFile.getName().replaceAll("^(.*)-\\d{4}-\\d{2}-\\d{2}-\\d{2}-\\d{2}.apkg$", "$1.apkg")
.equals(colFile.getName().replace(".anki2",".apkg"))) {
deckBackups.add(aktFile);
}
}
Collections.sort(deckBackups);
File[] fileList = new File[deckBackups.size()];
deckBackups.toArray(fileList);
return fileList;
}
public static boolean deleteDeckBackups(String colFile, int keepNumber) {
return deleteDeckBackups(getBackups(new File(colFile)), keepNumber);
}
public static boolean deleteDeckBackups(File colFile, int keepNumber) {
return deleteDeckBackups(getBackups(colFile), keepNumber);
}
public static boolean deleteDeckBackups(File[] backups, int keepNumber) {
if (backups == null) {
return false;
}
for (int i = 0; i < backups.length - keepNumber; i++) {
backups[i].delete();
Timber.e("deleteDeckBackups: backup file "+backups[i].getPath()+ " deleted.");
}
return true;
}
public static boolean removeDir(File dir) {
if (dir.isDirectory()) {
File[] files = dir.listFiles();
for (File aktFile : files) {
removeDir(aktFile);
}
}
return dir.delete();
}
}