Skip to content

Commit

Permalink
Merge pull request #400 from FineFindus/feat/import-export
Browse files Browse the repository at this point in the history
feat: implement import/export of settings
  • Loading branch information
LucasGGamerM authored May 8, 2024
2 parents b34a855 + b736cf2 commit 02c8a56
Show file tree
Hide file tree
Showing 4 changed files with 190 additions and 6 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@
import android.app.Activity;
import android.content.ClipData;
import android.content.ClipboardManager;
import android.content.ComponentName;
import android.content.Intent;
import android.content.SharedPreferences;
import android.content.pm.PackageManager;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.util.Log;
Expand All @@ -11,6 +16,13 @@
import android.widget.TextView;
import android.widget.Toast;

import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import com.google.gson.ToNumberPolicy;

import org.joinmastodon.android.BuildConfig;
import org.joinmastodon.android.GlobalUserPreferences;
import org.joinmastodon.android.MastodonApp;
Expand All @@ -21,27 +33,26 @@
import org.joinmastodon.android.fragments.HasAccountID;
import org.joinmastodon.android.model.viewmodel.CheckableListItem;
import org.joinmastodon.android.model.viewmodel.ListItem;
import org.joinmastodon.android.ui.M3AlertDialogBuilder;
import org.joinmastodon.android.ui.Snackbar;
import org.joinmastodon.android.ui.utils.UiUtils;
import org.joinmastodon.android.updater.GithubSelfUpdater;
import org.joinmastodon.android.utils.FileProvider;

import java.io.BufferedReader;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileWriter;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.StringReader;
import java.nio.charset.Charset;
import java.nio.file.Files;
import java.time.Instant;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.time.format.FormatStyle;
import java.util.ArrayList;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

import androidx.recyclerview.widget.RecyclerView;
import me.grishka.appkit.imageloader.ImageCache;
Expand All @@ -51,14 +62,15 @@

public class SettingsAboutAppFragment extends BaseSettingsFragment<Void> implements HasAccountID{
private static final String TAG="SettingsAboutAppFragment";
private static final int IMPORT_RESULT=314;
private ListItem<Void> mediaCacheItem, copyCrashLogItem;
private CheckableListItem<Void> enablePreReleasesItem;
private AccountSession session;
private boolean timelineCacheCleared=false;
private File crashLogFile=new File(MastodonApp.context.getFilesDir(), "crash.log");

// MOSHIDON
private ListItem<Void> clearRecentEmojisItem;
private ListItem<Void> clearRecentEmojisItem, exportItem, importItem;
@Override
public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
Expand All @@ -73,6 +85,8 @@ public void onCreate(Bundle savedInstanceState){
new ListItem<>(R.string.mo_settings_contribute, 0, R.drawable.ic_fluent_open_24_regular, i->UiUtils.launchWebBrowser(getActivity(), getString(R.string.mo_repo_url))),
new ListItem<>(R.string.settings_tos, 0, R.drawable.ic_fluent_open_24_regular, i->UiUtils.launchWebBrowser(getActivity(), "https://"+session.domain+"/terms")),
new ListItem<>(R.string.settings_privacy_policy, 0, R.drawable.ic_fluent_open_24_regular, i->UiUtils.launchWebBrowser(getActivity(), getString(R.string.privacy_policy_url)), 0, true),
exportItem=new ListItem<>(R.string.export_settings_title, R.string.export_settings_summary, R.drawable.ic_fluent_arrow_export_24_filled, this::onExportClick),
importItem=new ListItem<>(R.string.import_settings_title, R.string.import_settings_summary, R.drawable.ic_fluent_arrow_import_24_filled, this::onImportClick, 0, true),
clearRecentEmojisItem=new ListItem<>(R.string.mo_clear_recent_emoji, 0, this::onClearRecentEmojisClick),
mediaCacheItem=new ListItem<>(R.string.settings_clear_cache, 0, this::onClearMediaCacheClick),
new ListItem<>(getString(R.string.sk_settings_clear_timeline_cache), session.domain, this::onClearTimelineCacheClick),
Expand Down Expand Up @@ -146,6 +160,149 @@ private void onClearRecentEmojisClick(ListItem<?> item){
Toast.makeText(getContext(), R.string.mo_recent_emoji_cleared, Toast.LENGTH_SHORT).show();
}

private void onExportClick(ListItem<?> item){
Gson gson = new Gson();
JsonObject jsonObject = new JsonObject();
jsonObject.addProperty("versionName", BuildConfig.VERSION_NAME);
jsonObject.addProperty("versionCode", BuildConfig.VERSION_CODE);

// GlobalUserPreferences
//TODO: remove prefs that should not be exported
JsonElement je = gson.toJsonTree(GlobalUserPreferences.getPrefs().getAll());
jsonObject.add("GlobalUserPreferences", je);

// add account local prefs
for(AccountSession accountSession: AccountSessionManager.getInstance().getLoggedInAccounts()) {
Map<String, ?> prefs = accountSession.getRawLocalPreferences().getAll();
//TODO: remove prefs that should not be exported
JsonElement accountPrefs = gson.toJsonTree(prefs);
jsonObject.add(accountSession.self.id, accountPrefs);
}

try {
File file = new File(getContext().getCacheDir(), "moshidon-exported-settings.json");
FileWriter writer = new FileWriter(file);
writer.write(jsonObject.toString());
writer.flush();
writer.close();

Intent intent = new Intent(Intent.ACTION_SEND);
intent.setType("application/json");
Uri outputUri = FileProvider.getUriForFile(getContext(), getContext().getPackageName() + ".fileprovider", file);
intent.putExtra(Intent.EXTRA_STREAM, outputUri);
startActivity(Intent.createChooser(intent, getContext().getString(R.string.export_settings_share)));
} catch (IOException e) {
Toast.makeText(getContext(), getContext().getString(R.string.export_settings_fail), Toast.LENGTH_SHORT).show();
Log.w(TAG, e);
}
}

private void onImportClick(ListItem<?> item){
new M3AlertDialogBuilder(getContext())
.setTitle(R.string.import_settings_confirm)
.setIcon(R.drawable.ic_fluent_warning_24_regular)
.setMessage(R.string.import_settings_confirm_body)
.setPositiveButton(R.string.ok, (dialogInterface, i) -> {
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
intent.addCategory(Intent.CATEGORY_OPENABLE);
intent.setType("application/json");
startActivityForResult(intent, IMPORT_RESULT);
})
.setNegativeButton(R.string.cancel, null)
.show();
}

@Override
public void onActivityResult(int requestCode, int resultCode, Intent data){
if(requestCode==IMPORT_RESULT && resultCode==Activity.RESULT_OK){
Uri uri=data.getData();
if(uri==null){
return;
}
try{
InputStream inputStream=getContext().getContentResolver().openInputStream(uri);
if(inputStream==null)
return;
BufferedReader reader=new BufferedReader(new InputStreamReader(inputStream));
StringBuilder stringBuilder=new StringBuilder();
String line;
while((line=reader.readLine())!=null){
stringBuilder.append(line);
}
inputStream.close();
String jsonString=stringBuilder.toString();

Gson gson=new GsonBuilder().setObjectToNumberStrategy(ToNumberPolicy.LONG_OR_DOUBLE).create();
JsonObject jsonObject=JsonParser.parseString(jsonString).getAsJsonObject();

//check if json has required attributes
if(!(jsonObject.has("versionName") && jsonObject.has("versionCode") && jsonObject.has("GlobalUserPreferences"))){
Toast.makeText(getContext(), getContext().getString(R.string.import_settings_failed), Toast.LENGTH_SHORT).show();
return;
}
String versionName=jsonObject.get("versionName").getAsString();
int versionCode=jsonObject.get("versionCode").getAsInt();
Log.i(TAG, "onActivityResult: Reading exported settings ("+versionName+" "+versionCode+")");

// retrieve GlobalUserPreferences
Map<String, ?> jsonGlobalPrefs=gson.fromJson(jsonObject.getAsJsonObject("GlobalUserPreferences"), Map.class);
SharedPreferences.Editor globalPrefsEditor=GlobalUserPreferences.getPrefs().edit();
for(String key : jsonGlobalPrefs.keySet()){
Object value=jsonGlobalPrefs.get(key);
if(value==null)
continue;
savePrefValue(globalPrefsEditor, key, value);
}

// retrieve LocalPreferences for all logged in accounts
//TODO: maybe show a dialog for which accounts to import?
for(AccountSession accountSession : AccountSessionManager.getInstance().getLoggedInAccounts()){
if(!jsonObject.has(accountSession.self.id))
continue;
Map<String, ?> prefs=gson.fromJson(jsonObject.getAsJsonObject(accountSession.self.id), Map.class);

SharedPreferences.Editor prefEditor=accountSession.getRawLocalPreferences().edit();
for(String key : prefs.keySet()){
Object value=prefs.get(key);
if(value==null)
continue;
savePrefValue(prefEditor, key, value);
}
}

// restart app to apply new preferences
// https://stackoverflow.com/a/46848226
PackageManager packageManager=getContext().getPackageManager();
Intent intent=packageManager.getLaunchIntentForPackage(getContext().getPackageName());
ComponentName componentName=intent.getComponent();
Intent mainIntent=Intent.makeRestartActivityTask(componentName);
// Required for API 34 and later
// Ref: https://developer.android.com/about/versions/14/behavior-changes-14#safer-intents
mainIntent.setPackage(getContext().getPackageName());
getContext().startActivity(mainIntent);
Runtime.getRuntime().exit(0);
}catch(IOException e){
Log.w(TAG, e);
Toast.makeText(getContext(), getContext().getString(R.string.import_settings_failed), Toast.LENGTH_SHORT).show();
}
}
}

private void savePrefValue(SharedPreferences.Editor editor, String key, Object value) {
if(value.getClass().equals(Boolean.class))
editor.putBoolean(key, (Boolean) value);
// gson parses all numbers either long (for int) or double (the rest)
else if(value.getClass().equals(Long.class))
editor.putInt(key, ((Long) value).intValue());
else if(value.getClass().equals(Double.class))
editor.putFloat(key, ((Double) value).floatValue());
else
editor.putString(key, String.valueOf(value));
//explicitly immediately since the app will restarted soon after
// and it may not have the time to write the values in the background
editor.commit();
}

private void updateMediaCacheItem(){
long size=ImageCache.getInstance(getActivity()).getDiskCache().size();
mediaCacheItem.subtitle=UiUtils.formatFileSize(getActivity(), size, false);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M2.75,4.504a0.75,0.75 0,0 1,0.743 0.648l0.007,0.102v13.499a0.75,0.75 0,0 1,-1.493 0.101L2,18.753v-13.5a0.75,0.75 0,0 1,0.75 -0.75ZM15.21,6.387 L15.293,6.293a1,1 0,0 1,1.32 -0.083l0.094,0.083 4.997,4.998a1,1 0,0 1,0.083 1.32l-0.083,0.093 -4.996,5.004a1,1 0,0 1,-1.499 -1.32l0.083,-0.094L18.581,13L6,13a1,1 0,0 1,-0.993 -0.883L5,12a1,1 0,0 1,0.883 -0.993L6,11h12.584l-3.291,-3.293a1,1 0,0 1,-0.083 -1.32l0.083,-0.094 -0.083,0.094Z"
android:fillColor="#212121"/>
</vector>
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M21.25,4.5a0.75,0.75 0,0 1,0.743 0.648L22,5.25v13.5a0.75,0.75 0,0 1,-1.493 0.102l-0.007,-0.102L20.5,5.25a0.75,0.75 0,0 1,0.75 -0.75ZM12.21,6.387 L12.293,6.293a1,1 0,0 1,1.32 -0.083l0.094,0.083 4.997,4.998a1,1 0,0 1,0.083 1.32l-0.083,0.093 -4.996,5.004a1,1 0,0 1,-1.499 -1.32l0.083,-0.094L15.581,13L3,13a1,1 0,0 1,-0.993 -0.883L2,12a1,1 0,0 1,0.883 -0.993L3,11h12.584l-3.291,-3.293a1,1 0,0 1,-0.083 -1.32l0.083,-0.094 -0.083,0.094Z"
android:fillColor="#212121"/>
</vector>
9 changes: 9 additions & 0 deletions mastodon/src/main/res/values/strings_mo.xml
Original file line number Diff line number Diff line change
Expand Up @@ -120,4 +120,13 @@
<string name="mo_blocked_accounts">Blocked accounts</string>
<!-- <string name="mo_blocks">Blocks</string>-->
<string name="mo_mute_notifications">Hide notifications from this user?</string>
<string name="import_settings_confirm">Confirm to import settings?</string>
<string name="import_settings_confirm_body">All current settings and timelines will be overwritten! This action cannot be undone.</string>
<string name="import_settings_failed">Failed to import settings</string>
<string name="export_settings_share">Export Settings</string>
<string name="export_settings_fail">Failed to export settings</string>
<string name="export_settings_title">Export settings</string>
<string name="export_settings_summary">Export all logged-in accounts\' settings and timelines</string>
<string name="import_settings_title">Import settings</string>
<string name="import_settings_summary">Import previously exported settings and timelines</string>
</resources>

0 comments on commit 02c8a56

Please sign in to comment.