Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Opt-in automatic preferences file migration #775

Merged
merged 2 commits into from
Jan 7, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@

import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;

import de.robv.android.xposed.XC_MethodHook;
Expand Down Expand Up @@ -62,6 +64,7 @@ protected void beforeHookedMethod(MethodHookParam param) {
boolean isModule = false;
int xposedminversion = -1;
boolean xposedsharedprefs = false;
boolean xposedmigrateprefs = false;
try {
Map<String, Object> metaData = MetaDataReader.getMetaData(new File(appInfo.sourceDir));
isModule = metaData.containsKey("xposedmodule");
Expand All @@ -73,6 +76,7 @@ protected void beforeHookedMethod(MethodHookParam param) {
xposedminversion = MetaDataReader.extractIntPart((String) minVersionRaw);
}
xposedsharedprefs = metaData.containsKey("xposedsharedprefs");
xposedmigrateprefs = metaData.containsKey("xposedmigrateprefs");
}
} catch (NumberFormatException | IOException e) {
Hookers.logE("ApkParser fails", e);
Expand All @@ -90,11 +94,32 @@ protected void afterHookedMethod(MethodHookParam param) {
}
}
});
final boolean migratePrefs = xposedmigrateprefs;
XposedHelpers.findAndHookMethod(ContextImpl.class, "getPreferencesDir", new XC_MethodHook() {
@SuppressLint({"SetWorldReadable", "WorldReadableFiles"})
@Override
protected void afterHookedMethod(MethodHookParam param) {
param.setResult(new File(ConfigManager.getPrefsPath(appInfo.packageName)));
File newDir = new File(ConfigManager.getPrefsPath(appInfo.packageName));
if (migratePrefs) {
File oldDir = (File) param.getResult();
for (File oldFile : oldDir.listFiles()) {
Path oldPath = oldFile.toPath();
if (!Files.isSymbolicLink(oldPath)) {
Utils.logD("Migrating prefs file: " + oldFile.getAbsolutePath());
Path newPath = new File(newDir, oldFile.getName()).toPath();
try {
Files.move(oldPath, newPath);
try {
Files.createSymbolicLink(oldPath, newPath);
} catch (IOException e) {
Utils.logD("Symlink creation failed", e);
}
} catch (IOException e) {
Utils.logD("File move operation failed", e);
}
}
}
}
param.setResult(newDir);
}
});
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,14 @@
import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Path;
import java.nio.file.StandardWatchEventKinds;
import java.nio.file.WatchEvent;
import java.nio.file.WatchKey;
import java.nio.file.WatchService;
import java.security.MessageDigest;
import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
Expand All @@ -28,22 +36,36 @@
*/
public final class XSharedPreferences implements SharedPreferences {
private static final String TAG = "XSharedPreferences";
private static final HashMap<Path, PrefsData> mInstances = new HashMap<>();
private static final Object mContent = new Object();
private static Thread mDaemon = null;
private static WatchService mWatcher;
private final HashMap<OnSharedPreferenceChangeListener, Object> mListeners = new HashMap<>();
private final File mFile;
private final String mFilename;
private Map<String, Object> mMap;
private boolean mLoaded = false;
private long mLastModified;
private long mFileSize;

static {
try {
mWatcher = new File(XposedInit.prefsBasePath).toPath().getFileSystem().newWatchService();
Log.d(TAG, "Created WatchService instance");
} catch (IOException e) {
Log.e(TAG, "Failed to create WatchService", e);
}
}

/**
* Read settings from the specified file.
*
* @param prefFile The file to read the preferences from.
*/
public XSharedPreferences(File prefFile) {
mFile = prefFile;
mFilename = mFile.getAbsolutePath();
startLoadFromDisk();
mFilename = prefFile.getAbsolutePath();
init();
}

/**
Expand Down Expand Up @@ -95,9 +117,103 @@ public XSharedPreferences(String packageName, String prefFileName) {
mFile = new File(Environment.getDataDirectory(), "data/" + packageName + "/shared_prefs/" + prefFileName + ".xml");
}
mFilename = mFile.getAbsolutePath();
init();
}

private void tryRegisterWatcher() {
Path path = mFile.toPath();
if (mInstances.containsKey(path)) {
return;
}
try {
path.getParent().register(mWatcher, StandardWatchEventKinds.ENTRY_CREATE, StandardWatchEventKinds.ENTRY_MODIFY, StandardWatchEventKinds.ENTRY_DELETE);
mInstances.put(path, new PrefsData(this));
Log.d(TAG, "Registered file watcher for " + path);
} catch (Exception e) {
Log.d(TAG, "Failed to register file watcher", e);
}
}

private void init() {
if (mDaemon == null || !mDaemon.isAlive()) {
mDaemon = new Thread() {
@Override
public void run() {
Log.d(TAG, "Daemon thread started");
while (true) {
WatchKey key;
try {
key = mWatcher.take();
} catch (InterruptedException ignored) {
return;
}
for (WatchEvent<?> event : key.pollEvents()) {
WatchEvent.Kind<?> kind = event.kind();
if (kind == StandardWatchEventKinds.OVERFLOW) {
continue;
}
Path dir = (Path)key.watchable();
Path path = dir.resolve((Path)event.context());
String pathStr = path.toString();
Log.v(TAG, "File " + path.toString() + " event: " + kind.name());
// We react to both real and backup files due to rare race conditions
if (pathStr.endsWith(".bak")) {
if (kind != StandardWatchEventKinds.ENTRY_DELETE) {
continue;
} else {
pathStr = path.getFileName().toString();
path = dir.resolve(pathStr.substring(0, pathStr.length() - 4));
}
} else if (SELinuxHelper.getAppDataFileService().checkFileExists(pathStr + ".bak")) {
continue;
}
PrefsData data = mInstances.get(path);
if (data != null && data.hasChanged()) {
for (OnSharedPreferenceChangeListener l : data.mPrefs.mListeners.keySet()) {
try {
l.onSharedPreferenceChanged(data.mPrefs, null);
} catch (Throwable t) {
Log.e(TAG, "Fail in preference change listener", t);
}
}
}
}
key.reset();
}
}
};
mDaemon.setName(TAG + "-Daemon");
mDaemon.setDaemon(true);
mDaemon.start();
}
tryRegisterWatcher();
startLoadFromDisk();
}

private static long tryGetFileSize(String filename) {
try {
return SELinuxHelper.getAppDataFileService().getFileSize(filename);
} catch (IOException ignored) {
return 0;
}
}

private static byte[] tryGetFileHash(String filename) {
try {
MessageDigest md = MessageDigest.getInstance("MD5");
try (InputStream is = SELinuxHelper.getAppDataFileService().getFileInputStream(filename)) {
byte[] buf = new byte[4096];
int read;
while ((read = is.read(buf)) != -1) {
md.update(buf, 0, read);
}
}
return md.digest();
} catch (Exception ignored) {
return new byte[0];
}
}

/**
* Tries to make the preferences file world-readable.
*
Expand All @@ -117,7 +233,11 @@ public boolean makeWorldReadable() {
if (!mFile.exists()) // Just in case - the file should never be created if it doesn't exist.
return false;

return mFile.setReadable(true, false);
if (!mFile.setReadable(true, false))
return false;

tryRegisterWatcher();
return true;
}

/**
Expand Down Expand Up @@ -194,8 +314,9 @@ private void loadFromDiskLocked() {
* <p><strong>Warning:</strong> With enforcing SELinux, this call might be quite expensive.
*/
public synchronized void reload() {
if (hasFileChanged())
startLoadFromDisk();
if (hasFileChanged()) {
init();
}
}

/**
Expand Down Expand Up @@ -329,22 +450,53 @@ public Editor edit() {
throw new UnsupportedOperationException("read-only implementation");
}

/**
* @deprecated Not supported by this implementation.
*/
@Deprecated
@Override
public void registerOnSharedPreferenceChangeListener(OnSharedPreferenceChangeListener listener) {
throw new UnsupportedOperationException("listeners are not supported in this implementation");
synchronized(this) {
mListeners.put(listener, mContent);
}
}

/**
* @deprecated Not supported by this implementation.
*/
@Deprecated
@Override
public void unregisterOnSharedPreferenceChangeListener(OnSharedPreferenceChangeListener listener) {
throw new UnsupportedOperationException("listeners are not supported in this implementation");
synchronized(this) {
mListeners.remove(listener);
}
}

private static class PrefsData {
public final XSharedPreferences mPrefs;
private long mSize;
private byte[] mHash;

public PrefsData(XSharedPreferences prefs) {
mPrefs = prefs;
mSize = tryGetFileSize(prefs.mFilename);
mHash = tryGetFileHash(prefs.mFilename);
}

public boolean hasChanged() {
long size = tryGetFileSize(mPrefs.mFilename);
if (size < 1) {
Log.d(TAG, "Ignoring empty prefs file");
return false;
}
if (size != mSize) {
mSize = size;
mHash = tryGetFileHash(mPrefs.mFilename);
Log.d(TAG, "Prefs file size changed");
return true;
}
byte[] hash = tryGetFileHash(mPrefs.mFilename);
if (!Arrays.equals(hash, mHash)) {
mHash = hash;
Log.d(TAG, "Prefs file hash changed");
return true;
}
Log.d(TAG, "Prefs file not changed");
return false;
}
}
}