diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsAboutAppFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsAboutAppFragment.java index 6bd3a77b8a..17585ff8b0 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsAboutAppFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsAboutAppFragment.java @@ -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; @@ -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; @@ -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; @@ -51,6 +62,7 @@ public class SettingsAboutAppFragment extends BaseSettingsFragment implements HasAccountID{ private static final String TAG="SettingsAboutAppFragment"; + private static final int IMPORT_RESULT=314; private ListItem mediaCacheItem, copyCrashLogItem; private CheckableListItem enablePreReleasesItem; private AccountSession session; @@ -58,7 +70,7 @@ public class SettingsAboutAppFragment extends BaseSettingsFragment impleme private File crashLogFile=new File(MastodonApp.context.getFilesDir(), "crash.log"); // MOSHIDON - private ListItem clearRecentEmojisItem; + private ListItem clearRecentEmojisItem, exportItem, importItem; @Override public void onCreate(Bundle savedInstanceState){ super.onCreate(savedInstanceState); @@ -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), @@ -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 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 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 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); diff --git a/mastodon/src/main/res/drawable/ic_fluent_arrow_export_24_filled.xml b/mastodon/src/main/res/drawable/ic_fluent_arrow_export_24_filled.xml new file mode 100644 index 0000000000..d0b0c0b1b5 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_arrow_export_24_filled.xml @@ -0,0 +1,9 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_arrow_import_24_filled.xml b/mastodon/src/main/res/drawable/ic_fluent_arrow_import_24_filled.xml new file mode 100644 index 0000000000..267c0b8118 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_arrow_import_24_filled.xml @@ -0,0 +1,9 @@ + + + diff --git a/mastodon/src/main/res/values/strings_mo.xml b/mastodon/src/main/res/values/strings_mo.xml index 3649ce74d1..78dce7e6fa 100644 --- a/mastodon/src/main/res/values/strings_mo.xml +++ b/mastodon/src/main/res/values/strings_mo.xml @@ -120,4 +120,13 @@ Blocked accounts Hide notifications from this user? + Confirm to import settings? + All current settings and timelines will be overwritten! This action cannot be undone. + Failed to import settings + Export Settings + Failed to export settings + Export settings + Export all logged-in accounts\' settings and timelines + Import settings + Import previously exported settings and timelines \ No newline at end of file