migrate to scoped storage and API 30 (#741)
parent
24fc27b09e
commit
d2f07ba3b6
@ -0,0 +1,170 @@
|
||||
package com.m2049r.xmrwallet.util;
|
||||
|
||||
import android.Manifest;
|
||||
import android.content.Context;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.os.Environment;
|
||||
import android.preference.PreferenceManager;
|
||||
|
||||
import com.m2049r.xmrwallet.BuildConfig;
|
||||
import com.m2049r.xmrwallet.model.WalletManager;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.FileReader;
|
||||
import java.io.IOException;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import timber.log.Timber;
|
||||
|
||||
@RequiredArgsConstructor
|
||||
public class LegacyStorageHelper {
|
||||
final private File srcDir;
|
||||
final private File dstDir;
|
||||
|
||||
static public void migrateWallets(Context context) {
|
||||
try {
|
||||
if (isStorageMigrated(context)) return;
|
||||
if (!hasWritePermission(context)) {
|
||||
// nothing to migrate, so don't try again
|
||||
setStorageMigrated(context);
|
||||
return;
|
||||
}
|
||||
final File oldRoot = getWalletRoot();
|
||||
if (!oldRoot.exists()) {
|
||||
// nothing to migrate, so don't try again
|
||||
setStorageMigrated(context);
|
||||
return;
|
||||
}
|
||||
final File newRoot = Helper.getWalletRoot(context);
|
||||
(new LegacyStorageHelper(oldRoot, newRoot)).migrate();
|
||||
setStorageMigrated(context); // done it once - don't try again
|
||||
} catch (IllegalStateException ex) {
|
||||
Timber.d(ex);
|
||||
// nothing we can do here
|
||||
}
|
||||
}
|
||||
|
||||
public void migrate() {
|
||||
String addressPrefix = WalletManager.getInstance().addressPrefix();
|
||||
File[] wallets = srcDir.listFiles((dir, filename) -> filename.endsWith(".keys"));
|
||||
for (File wallet : wallets) {
|
||||
final String walletName = wallet.getName().substring(0, wallet.getName().length() - ".keys".length());
|
||||
if (addressPrefix.indexOf(getAddress(walletName).charAt(0)) < 0) {
|
||||
Timber.d("skipping %s", walletName);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
copy(walletName);
|
||||
} catch (IOException ex) { // something failed - try to clean up
|
||||
deleteDst(walletName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// return "@" by default so we don't need to deal with null stuff
|
||||
private String getAddress(String walletName) {
|
||||
File addressFile = new File(srcDir, walletName + ".address.txt");
|
||||
if (!addressFile.exists()) return "@";
|
||||
try (BufferedReader addressReader = new BufferedReader(new FileReader(addressFile))) {
|
||||
return addressReader.readLine();
|
||||
} catch (IOException ex) {
|
||||
Timber.d(ex.getLocalizedMessage());
|
||||
}
|
||||
return "@";
|
||||
}
|
||||
|
||||
private void copy(String walletName) throws IOException {
|
||||
final String dstName = getUniqueName(dstDir, walletName);
|
||||
copyFile(new File(srcDir, walletName), new File(dstDir, dstName));
|
||||
copyFile(new File(srcDir, walletName + ".keys"), new File(dstDir, dstName + ".keys"));
|
||||
}
|
||||
|
||||
private void deleteDst(String walletName) {
|
||||
// do our best, but if it fails, it fails
|
||||
(new File(dstDir, walletName)).delete();
|
||||
(new File(dstDir, walletName + ".keys")).delete();
|
||||
}
|
||||
|
||||
private void copyFile(File src, File dst) throws IOException {
|
||||
if (!src.exists()) return;
|
||||
Timber.d("%s => %s", src.getAbsolutePath(), dst.getAbsolutePath());
|
||||
try (FileChannel inChannel = new FileInputStream(src).getChannel();
|
||||
FileChannel outChannel = new FileOutputStream(dst).getChannel()) {
|
||||
inChannel.transferTo(0, inChannel.size(), outChannel);
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isExternalStorageWritable() {
|
||||
String state = Environment.getExternalStorageState();
|
||||
return Environment.MEDIA_MOUNTED.equals(state);
|
||||
}
|
||||
|
||||
private static File getWalletRoot() {
|
||||
if (!isExternalStorageWritable())
|
||||
throw new IllegalStateException();
|
||||
|
||||
// wallet folder for legacy (pre-Q) installations
|
||||
final String FLAVOR_SUFFIX =
|
||||
(BuildConfig.FLAVOR.startsWith("prod") ? "" : "." + BuildConfig.FLAVOR)
|
||||
+ (BuildConfig.DEBUG ? "-debug" : "");
|
||||
final String WALLET_DIR = "monerujo" + FLAVOR_SUFFIX;
|
||||
|
||||
File dir = new File(Environment.getExternalStorageDirectory(), WALLET_DIR);
|
||||
if (!dir.exists() || !dir.isDirectory())
|
||||
throw new IllegalStateException();
|
||||
return dir;
|
||||
}
|
||||
|
||||
private static boolean hasWritePermission(Context context) {
|
||||
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) {
|
||||
return context.checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_DENIED;
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private static final Pattern WALLET_PATTERN = Pattern.compile("^(.+) \\(([0-9]+)\\).keys$");
|
||||
|
||||
private static String getUniqueName(File root, String name) {
|
||||
if (!(new File(root, name + ".keys")).exists()) // <name> does not exist => it's ok to use
|
||||
return name;
|
||||
|
||||
File[] wallets = root.listFiles(
|
||||
(dir, filename) -> {
|
||||
Matcher m = WALLET_PATTERN.matcher(filename);
|
||||
if (m.find())
|
||||
return m.group(1).equals(name);
|
||||
else return false;
|
||||
});
|
||||
if (wallets.length == 0) return name + " (1)";
|
||||
int maxIndex = 0;
|
||||
for (File wallet : wallets) {
|
||||
try {
|
||||
final Matcher m = WALLET_PATTERN.matcher(wallet.getName());
|
||||
if (!m.find())
|
||||
throw new IllegalStateException("this must match as it did before");
|
||||
final int index = Integer.parseInt(m.group(2));
|
||||
if (index > maxIndex) maxIndex = index;
|
||||
} catch (NumberFormatException ex) {
|
||||
// this cannot happen & we can ignore it if it does
|
||||
}
|
||||
}
|
||||
return name + " (" + (maxIndex + 1) + ")";
|
||||
}
|
||||
|
||||
private static final String MIGRATED_KEY = "migrated_legacy_storage";
|
||||
|
||||
public static boolean isStorageMigrated(Context context) {
|
||||
return PreferenceManager.getDefaultSharedPreferences(context).getBoolean(MIGRATED_KEY, false);
|
||||
}
|
||||
|
||||
public static void setStorageMigrated(Context context) {
|
||||
PreferenceManager.getDefaultSharedPreferences(context).edit().putBoolean(MIGRATED_KEY, true).apply();
|
||||
}
|
||||
}
|
@ -0,0 +1,64 @@
|
||||
package com.m2049r.xmrwallet.util;
|
||||
|
||||
import android.content.Context;
|
||||
import android.net.Uri;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.Date;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipOutputStream;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
@RequiredArgsConstructor
|
||||
public class ZipBackup {
|
||||
final private Context context;
|
||||
final private String walletName;
|
||||
|
||||
private ZipOutputStream zip;
|
||||
|
||||
public void writeTo(Uri zipUri) throws IOException {
|
||||
if (zip != null)
|
||||
throw new IllegalStateException("zip already initialized");
|
||||
try {
|
||||
zip = new ZipOutputStream(context.getContentResolver().openOutputStream(zipUri));
|
||||
|
||||
final File walletRoot = Helper.getWalletRoot(context);
|
||||
addFile(new File(walletRoot, walletName + ".keys"));
|
||||
addFile(new File(walletRoot, walletName));
|
||||
|
||||
zip.close();
|
||||
} finally {
|
||||
if (zip != null) zip.close();
|
||||
}
|
||||
}
|
||||
|
||||
private void addFile(File file) throws IOException {
|
||||
if (!file.exists()) return; // ignore missing files (e.g. the cache file might not exist)
|
||||
ZipEntry entry = new ZipEntry(file.getName());
|
||||
zip.putNextEntry(entry);
|
||||
writeFile(file);
|
||||
zip.closeEntry();
|
||||
}
|
||||
|
||||
private void writeFile(File source) throws IOException {
|
||||
try (InputStream is = new FileInputStream(source)) {
|
||||
byte[] buffer = new byte[8192];
|
||||
int length;
|
||||
while ((length = is.read(buffer)) > 0) {
|
||||
zip.write(buffer, 0, length);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static final SimpleDateFormat DATETIME_FORMATTER =
|
||||
new SimpleDateFormat("yyyy-MM-dd'T'HH-mm-ss");
|
||||
|
||||
public String getBackupName() {
|
||||
return walletName + " " + DATETIME_FORMATTER.format(new Date());
|
||||
}
|
||||
}
|
@ -0,0 +1,139 @@
|
||||
package com.m2049r.xmrwallet.util;
|
||||
|
||||
import android.content.Context;
|
||||
import android.net.Uri;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipInputStream;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import timber.log.Timber;
|
||||
|
||||
@RequiredArgsConstructor
|
||||
public class ZipRestore {
|
||||
final private Context context;
|
||||
final private Uri zipUri;
|
||||
|
||||
private File walletRoot;
|
||||
|
||||
private ZipInputStream zip;
|
||||
|
||||
public boolean restore() throws IOException {
|
||||
walletRoot = Helper.getWalletRoot(context);
|
||||
String walletName = testArchive();
|
||||
if (walletName == null) return false;
|
||||
|
||||
walletName = getUniqueName(walletName);
|
||||
|
||||
if (zip != null)
|
||||
throw new IllegalStateException("zip already initialized");
|
||||
try {
|
||||
zip = new ZipInputStream(context.getContentResolver().openInputStream(zipUri));
|
||||
for (ZipEntry entry = zip.getNextEntry(); entry != null; zip.closeEntry(), entry = zip.getNextEntry()) {
|
||||
File destination;
|
||||
final String name = entry.getName();
|
||||
if (name.endsWith(".keys")) {
|
||||
destination = new File(walletRoot, walletName + ".keys");
|
||||
} else if (name.endsWith(".address.txt")) {
|
||||
destination = new File(walletRoot, walletName + ".address.txt");
|
||||
} else {
|
||||
destination = new File(walletRoot, walletName);
|
||||
}
|
||||
writeFile(destination);
|
||||
}
|
||||
} finally {
|
||||
if (zip != null) zip.close();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private void writeFile(File destination) throws IOException {
|
||||
try (OutputStream os = new FileOutputStream(destination)) {
|
||||
byte[] buffer = new byte[8192];
|
||||
int length;
|
||||
while ((length = zip.read(buffer)) > 0) {
|
||||
os.write(buffer, 0, length);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// test the archive to contain files we expect & return the name of the contained wallet or null
|
||||
private String testArchive() {
|
||||
String walletName = null;
|
||||
boolean keys = false;
|
||||
ZipInputStream zipStream = null;
|
||||
try {
|
||||
zipStream = new ZipInputStream(context.getContentResolver().openInputStream(zipUri));
|
||||
for (ZipEntry entry = zipStream.getNextEntry(); entry != null;
|
||||
zipStream.closeEntry(), entry = zipStream.getNextEntry()) {
|
||||
if (entry.isDirectory())
|
||||
return null;
|
||||
final String name = entry.getName();
|
||||
if ((new File(name)).getParentFile() != null)
|
||||
return null;
|
||||
if (walletName == null) {
|
||||
if (name.endsWith(".keys")) {
|
||||
walletName = name.substring(0, name.length() - ".keys".length());
|
||||
keys = true; // we have they keys
|
||||
} else if (name.endsWith(".address.txt")) {
|
||||
walletName = name.substring(0, name.length() - ".address.txt".length());
|
||||
} else {
|
||||
walletName = name;
|
||||
}
|
||||
} else { // we have a wallet name
|
||||
if (name.endsWith(".keys")) {
|
||||
if (!name.equals(walletName + ".keys")) return null;
|
||||
keys = true; // we have they keys
|
||||
} else if (name.endsWith(".address.txt")) {
|
||||
if (!name.equals(walletName + ".address.txt")) return null;
|
||||
} else if (!name.equals(walletName)) return null;
|
||||
}
|
||||
}
|
||||
} catch (IOException ex) {
|
||||
return null;
|
||||
} finally {
|
||||
try {
|
||||
if (zipStream != null) zipStream.close();
|
||||
} catch (IOException ex) {
|
||||
Timber.w(ex);
|
||||
}
|
||||
}
|
||||
// we need the keys at least
|
||||
if (keys) return walletName;
|
||||
else return null;
|
||||
}
|
||||
|
||||
final static Pattern WALLET_PATTERN = Pattern.compile("^(.+) \\(([0-9]+)\\).keys$");
|
||||
|
||||
private String getUniqueName(String name) {
|
||||
if (!(new File(walletRoot, name + ".keys")).exists()) // <name> does not exist => it's ok to use
|
||||
return name;
|
||||
|
||||
File[] wallets = walletRoot.listFiles(
|
||||
(dir, filename) -> {
|
||||
Matcher m = WALLET_PATTERN.matcher(filename);
|
||||
if (m.find())
|
||||
return m.group(1).equals(name);
|
||||
else return false;
|
||||
});
|
||||
if (wallets.length == 0) return name + " (1)";
|
||||
int maxIndex = 0;
|
||||
for (File wallet : wallets) {
|
||||
try {
|
||||
final Matcher m = WALLET_PATTERN.matcher(wallet.getName());
|
||||
m.find();
|
||||
final int index = Integer.parseInt(m.group(2));
|
||||
if (index > maxIndex) maxIndex = index;
|
||||
} catch (NumberFormatException ex) {
|
||||
// this cannot happen & we can ignore it if it does
|
||||
}
|
||||
}
|
||||
return name + " (" + (maxIndex + 1) + ")";
|
||||
}
|
||||
}
|
Loading…
Reference in new issue