/*
* Copyright (C) 2012-2016 The Android Money Manager Ex Project Team
*
* 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.money.manager.ex.utils;
import android.content.ContentProviderClient;
import android.content.ContentResolver;
import android.content.Context;
import android.database.Cursor;
import android.database.sqlite.SQLiteDatabase;
import android.database.sqlite.SQLiteDiskIOException;
import android.os.Build;
import android.os.Environment;
import android.support.annotation.NonNull;
import com.money.manager.ex.MmxContentProvider;
import com.money.manager.ex.MoneyManagerApplication;
import com.money.manager.ex.R;
import com.money.manager.ex.core.InfoKeys;
import com.money.manager.ex.core.UIHelper;
import com.money.manager.ex.database.MmxOpenHelper;
import com.money.manager.ex.datalayer.InfoRepositorySql;
import com.money.manager.ex.domainmodel.Info;
import com.money.manager.ex.home.DatabaseMetadata;
import com.money.manager.ex.home.RecentDatabasesProvider;
import com.money.manager.ex.settings.AppSettings;
import java.io.BufferedReader;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Pattern;
import javax.inject.Inject;
import dagger.Lazy;
import timber.log.Timber;
import static com.money.manager.ex.Constants.DEFAULT_DB_FILENAME;
/**
* Various database-related utility functions
*/
public class MmxDatabaseUtils {
public static void closeCursor(Cursor c) {
if (c == null || c.isClosed()) return;
c.close();
}
public static String[] getArgsForId(int id) {
String[] result = new String[] { Integer.toString(id) };
return result;
}
public static boolean isEncryptedDatabase(String dbPath) {
return dbPath.contains(".emb");
}
public static boolean isValidDbFile(String dbFilePath) {
File dbFile = new File(dbFilePath);
if (!dbFile.exists()) return false;
// extension
if (!dbFile.getName().endsWith(".mmb")) return false;
// also add .emb in the future.
if (!dbFile.canRead()) return false;
if (!dbFile.canWrite()) return false;
return true;
}
// Dynamic
@Inject
public MmxDatabaseUtils(Context context){
mContext = context;
// dependency injection
MoneyManagerApplication.getApp().iocComponent.inject(this);
}
@Inject Lazy<RecentDatabasesProvider> mDatabasesLazy;
@Inject Lazy<MmxOpenHelper> openHelper;
@Inject Lazy<InfoRepositorySql> infoRepositorySqlLazy;
private Context mContext;
public Context getContext() {
return mContext;
}
/**
* Runs SQLite pragma check on the database file.
* @return A boolean indicating whether the check was successfully completed.
*/
public boolean checkIntegrity() {
boolean result = openHelper.get().getReadableDatabase()
.isDatabaseIntegrityOk();
return result;
}
/**
* Checks if all the required tables are present.
* Should be expanded and improved to check for the whole schema.
* @return A boolean indicating whether the schema is correct.
*/
public boolean checkSchema() {
try {
return checkSchemaInternal();
} catch (Exception e) {
Timber.e(e, "checking schema");
return false;
}
}
public String createDatabase() {
return createDatabase(DEFAULT_DB_FILENAME);
}
/**
* Creates a new database file at the default location, with the given db file name.
* @param fileName File name for the new database. Extension .mmb will be appended if not
* included in the fileName. Excludes path!
* If null, a default file name will be used.
*/
public String createDatabase(@NonNull String fileName) {
String result = null;
try {
result = createDatabase_Internal(fileName);
} catch (Exception e) {
Timber.e(e, "creating database");
}
return result;
}
public boolean fixDuplicates() {
boolean result = false;
// check if there are duplicate records in Info Table
InfoRepositorySql repo = infoRepositorySqlLazy.get(); //new InfoRepositorySql(getContext());
List<Info> results = repo.loadAll(InfoKeys.DATEFORMAT);
if (results == null) return false;
if (results.size() > 1) {
// delete them, leaving only the first one
int keepId = results.get(0).getId();
for(Info toBeDeleted : results) {
int idToDelete = toBeDeleted.getId();
if (idToDelete != keepId) {
repo.delete(idToDelete);
}
}
} else {
// no duplicates found
result = true;
}
return result;
}
/**
* Gets the directory where the database is (to be) stored. New databases
* are created here by default.
* The directory is created if it does not exist.
* Ref: https://gist.github.com/granoeste/5574148
* @return the default database directory
*/
public String getDefaultDatabaseDirectory() {
File defaultFolder;
// try with the external storage first.
defaultFolder = getDbExternalStorageDirectory();
if (defaultFolder != null) return defaultFolder.getAbsolutePath();
defaultFolder = getExternalFilesDirectory();
if (defaultFolder != null) return defaultFolder.getAbsolutePath();
// Then use files dir.
defaultFolder = getPackageDirectory();
if (defaultFolder != null) return defaultFolder.getAbsolutePath();
return null;
}
/**
* Generates the default database path, including the filename. This is used for database
* creation and display of the default value during creation.
* @return The default database path.
*/
public String getDefaultDatabasePath() {
return getDefaultDatabaseDirectory()
.concat(File.separator).concat(DEFAULT_DB_FILENAME);
}
public String makePlaceholders(int len) {
if (len < 1) {
// It would lead to an invalid query anyway ..
throw new RuntimeException("No placeholders");
} else {
StringBuilder sb = new StringBuilder(len * 2 - 1);
sb.append("?");
for (int i = 1; i < len; i++) {
sb.append(",?");
}
return sb.toString();
}
}
/**
* Change the database used by the app.
* Sets the given database path (full path to the file) as the current database. Adds it to the
* recent files. Resets the data layer.
* All that is needed after this method is to (re-)start the Main Activity, which will read all
* the stored preferences.
* @return Indicator whether the database is valid for use.
*/
public boolean useDatabase(@NonNull DatabaseMetadata database) {
// check if the file is a valid database.
if (!isValidDbFile(database.localPath)) {
throw new IllegalArgumentException("Not a valid database file!");
}
// Set path in preferences.
new AppSettings(getContext()).getDatabaseSettings().setDatabasePath(database.localPath);
// Store the Recent Database entry.
boolean added = mDatabasesLazy.get().add(database);
if (!added) {
throw new RuntimeException("could not add to recent files");
}
// Switch database in the active data layer.
MoneyManagerApplication.getApp().initDb(database.localPath);
resetContentProvider();
return true;
}
// Private
private boolean checkSchemaInternal() {
boolean result = false;
// Get the names of all the tables from the generation script.
ArrayList<String> scriptTables;
try {
scriptTables = getAllTableNamesFromGenerationScript();
} catch (IOException | SQLiteDiskIOException ex) {
Timber.e(ex, "reading table names from generation script");
return false;
}
// get the list of all the tables from the database.
ArrayList<String> existingTables = getTableNamesFromDb();
// compare. retainAll, removeAll, addAll
scriptTables.removeAll(existingTables);
// If there is anything left, the script schema has more tables than the db.
if (!scriptTables.isEmpty()) {
StringBuilder message = new StringBuilder("Tables missing: ");
for(String table:scriptTables) {
message.append(table);
message.append(" ");
}
new UIHelper(getContext()).showToast(message.toString());
} else {
// everything matches
result = true;
}
return result;
}
private String createDatabase_Internal(String filename)
throws IOException {
filename = cleanupFilename(filename);
// it might be enough simply to generate the new filename and set it as the default database.
String location = getDefaultDatabaseDirectory();
String newFilePath = location.concat(File.separator).concat(filename);
// Create db file.
File dbFile = new File(newFilePath);
if (dbFile.exists()) {
throw new RuntimeException(getContext().getString(R.string.create_db_exists));
}
if (!dbFile.createNewFile()) {
throw new RuntimeException(getContext().getString(R.string.create_db_error));
}
// close connection
openHelper.get().close();
// store as the current database in preferences
new AppSettings(getContext()).getDatabaseSettings().setDatabasePath(newFilePath);
return newFilePath;
}
private String cleanupFilename(String filename) {
// trim any trailing or leading spaces
filename = filename.trim();
// check if filename already contains the extension
boolean containsExtension = Pattern.compile(Pattern.quote(".mmb"), Pattern.CASE_INSENSITIVE)
.matcher(filename)
.find();
if (!containsExtension) {
filename += ".mmb";
}
return filename;
}
private ArrayList<String> getAllTableNamesFromGenerationScript()
throws IOException {
InputStream inputStream = getContext().getResources().openRawResource(R.raw.tables_v1);
BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream));
String line = reader.readLine();
String textToMatch = "create table";
ArrayList<String> tableNames = new ArrayList<>();
while (line != null) {
boolean found = false;
if (line.contains(textToMatch)) found = true;
if (!found && line.contains(textToMatch.toUpperCase())) found = true;
if (found) {
// extract the table name from the instruction
line = line.replace(textToMatch, "");
line = line.replace(textToMatch.toUpperCase(), "");
line = line.replace("(", "");
// remove any empty spaces.
line = line.trim();
tableNames.add(line);
}
line = reader.readLine();
}
return tableNames;
}
/**
* /sdcard/MoneyManagerEx
* @return the location for the database in the publicly accessible storage
*/
private File getDbExternalStorageDirectory() {
// sdcard
File externalStorageDirectory = Environment.getExternalStorageDirectory();
if (externalStorageDirectory == null) return null;
if (!externalStorageDirectory.exists() || !externalStorageDirectory.isDirectory() || !externalStorageDirectory.canWrite()) {
return null;
}
// now create the app's directory in the root.
String defaultPath = externalStorageDirectory.getAbsolutePath()
.concat(File.separator).concat("MoneyManagerEx");
File defaultFolder = new File(defaultPath);
if (defaultFolder.exists() && defaultFolder.canRead() && defaultFolder.canWrite()) return defaultFolder;
if (!defaultFolder.exists()) {
// create the directory.
if (!defaultFolder.mkdirs()) {
Timber.w("could not create the storage directory %s", defaultPath);
return null;
}
}
return defaultFolder;
}
/**
* External files directory
* /storage/sdcard0/Android/data/package/files
* @return directory to store the database in external files dir.
*/
private File getExternalFilesDirectory() {
File externalFilesDir = getContext().getExternalFilesDir(null);
if (externalFilesDir == null) return null;
String dbString = externalFilesDir.getAbsolutePath().concat(File.separator)
.concat("databases");
File dbPath = new File(dbString);
if (dbPath.exists() && dbPath.canRead() && dbPath.canWrite()) return dbPath;
if (!dbPath.mkdir()) {
Timber.w("could not create databases directory in external files");
return null;
}
return dbPath;
}
/**
*
* @return app's files directory
*/
private File getPackageDirectory() {
// getFilesDir() = /data/data/package/files
File packageLocation = getContext().getFilesDir().getParentFile();
// or: getContext().getApplicationInfo().dataDir
// getContext().getFilesDir()
// internalFolder = "/data/data/" + getContext().getApplicationContext().getPackageName();
String dbDirectoryPath = packageLocation.getAbsolutePath()
.concat(File.separator)
.concat("databases");
File dbDirectory = new File(dbDirectoryPath);
if (dbDirectory.exists() && dbDirectory.canRead() && dbDirectory.canWrite()) return dbDirectory;
if (!dbDirectory.exists()) {
if (!dbDirectory.mkdir()) {
Timber.w("could not create databases directory");
return null;
}
}
return dbDirectory ;
}
/**
* Get all table Details from teh sqlite_master table in Db.
* @return An ArrayList of table details.
*/
private ArrayList<String> getTableNamesFromDb() {
SQLiteDatabase db = openHelper.get().getReadableDatabase();
Cursor c = db.rawQuery("SELECT name FROM sqlite_master WHERE type='table'", null);
ArrayList<String> result = new ArrayList<>();
int i = 0;
while (c.moveToNext()) {
String temp = c.getString(i);
result.add(temp);
}
c.close();
return result;
}
private void resetContentProvider() {
ContentResolver resolver = getContext().getContentResolver();
String authority = getContext().getApplicationContext().getPackageName() + ".provider";
ContentProviderClient client = resolver.acquireContentProviderClient(authority);
assert client != null;
MmxContentProvider provider = (MmxContentProvider) client.getLocalContentProvider();
assert provider != null;
provider.resetDatabase();
if (Build.VERSION.SDK_INT >= 24) {
client.close();
} else {
client.release();
}
}
}