diff --git a/extensions/twitter/src/main/java/app/morphe/extension/twitter/patches/logintoken/ExportLoginTokenFragment.java b/extensions/twitter/src/main/java/app/morphe/extension/twitter/patches/logintoken/ExportLoginTokenFragment.java new file mode 100644 index 000000000..b20066b20 --- /dev/null +++ b/extensions/twitter/src/main/java/app/morphe/extension/twitter/patches/logintoken/ExportLoginTokenFragment.java @@ -0,0 +1,185 @@ +package app.morphe.extension.twitter.patches.logintoken; + +import android.accounts.Account; +import android.accounts.AccountManager; +import android.app.Activity; +import android.app.AlertDialog; +import android.app.Fragment; +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ArrayAdapter; +import android.widget.Button; +import android.widget.Spinner; +import android.widget.TextView; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import app.morphe.extension.shared.StringRef; +import app.morphe.extension.shared.Utils; + +import java.io.OutputStream; + +/** + * A screen in piko settings to export or remove accounts from the app. + * This fragment takes an argument to select either the export or remove screen. + */ +@SuppressWarnings("deprecation") +public class ExportLoginTokenFragment extends Fragment { + + private static final int CREATE_FILE_REQUEST_CODE = 31; + private Account accountToSaveToFile; + private boolean isRemovingAccount = false; + + @Override + public void onCreate(@Nullable @org.jetbrains.annotations.Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + isRemovingAccount = getArguments().getBoolean("isRemoveAccount"); + } + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + return inflater.inflate(Utils.getResourceIdentifier("fragment_export_token", "layout"), container, false); + } + + @Override + public void onViewCreated(View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + + Spinner spinner = view.findViewById(Utils.getResourceIdentifier("spinner", "id")); + AccountManager accountManager = AccountManager.get(getContext()); + Account[] accounts = accountManager.getAccountsByType("com.twitter.android.auth.login"); + AccountArrayAdapter arrayAdapter = new AccountArrayAdapter(getContext(), android.R.layout.simple_spinner_dropdown_item, accounts); + spinner.setAdapter(arrayAdapter); + + TextView descriptionView = view.findViewById(Utils.getResourceIdentifier("textview_description", "id")); + Button button1 = view.findViewById(Utils.getResourceIdentifier("button1", "id")); + Button button2 = view.findViewById(Utils.getResourceIdentifier("button2", "id")); + + if (!isRemovingAccount) { + // Set by resource identifier to preserve formatting tags + descriptionView.setText(Utils.getResourceIdentifier("piko_login_token_export_screen_description", "string")); + + // Copy to clipboard button + button1.setText(StringRef.str("piko_login_token_export_copy_to_clipboard")); + button1.setOnClickListener(v -> { + try { + Account account = (Account) spinner.getSelectedItem(); + String jsonString = ImportExportLoginTokenPatch.createAccountJsonText(account); + Utils.setClipboard(jsonString); + Utils.showToastShort(StringRef.str("copied_to_clipboard")); + } catch (Exception e) { + Utils.showToastLong(StringRef.str("piko_pref_export_failed", StringRef.str("accounts_title"))); + app.morphe.extension.twitter.Utils.logger(e); + } + }); + + // Save to file button + button2.setText(StringRef.str("piko_login_token_export_save_to_file")); + button2.setOnClickListener(v -> { + Account account = (Account) spinner.getSelectedItem(); + accountToSaveToFile = account; + + Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT); + intent.addCategory(Intent.CATEGORY_OPENABLE); + intent.setType("application/json"); + intent.putExtra(Intent.EXTRA_TITLE, "piko_account_" + account.name); + startActivityForResult(intent, CREATE_FILE_REQUEST_CODE); + }); + } else { + // Set by resource identifier to preserve formatting tags + descriptionView.setText(Utils.getResourceIdentifier("piko_login_token_remove_screen_description", "string")); + + /* + * Show a button to remove account from the app. + * + * To remove the account, set a dummy auth token and intentionally trigger the automatic logout logic due to an error. + * removeAccount() cannot be used here as it sends a logout request to the server. + * removeAccountExplicitly() also cannot be used because it leaves unnecessary data in the app data. + * + * We cannot set an empty string as the token. This will result in an incomplete state without triggering automatic logout. + * If this happens, you can simply log out from the settings because the logout request will fail because the token is invalid. + */ + button1.setText(StringRef.str("piko_login_token_remove_account_button_text")); + button1.setOnClickListener(v -> { + // Show a confirmation dialog first + new AlertDialog.Builder(getContext()) + .setMessage(StringRef.str("piko_login_token_remove_account_confirm_text")) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(android.R.string.ok, (dialog, which) -> { + // Set dummy tokens + Account account = (Account) spinner.getSelectedItem(); + accountManager.setAuthToken(account, "com.twitter.android.oauth.token", "a"); + accountManager.setAuthToken(account, "com.twitter.android.oauth.token.secret", "a"); + // Show restart dialog + new AlertDialog.Builder(getContext()) + .setTitle(StringRef.str("piko_pref_success")) + .setMessage(StringRef.str("piko_login_token_import_success_restart_required")) + .setPositiveButton(android.R.string.ok, (dialog1, which1) -> Utils.restartApp(getContext())) + .setNegativeButton(android.R.string.cancel, null) + .setCancelable(false) + .show(); + }) + .show(); + }); + button2.setVisibility(View.GONE); + + // Show hint for single account + if (spinner.getAdapter().getCount() == 1) { + view.findViewById(Utils.getResourceIdentifier("textview_hintbox", "id")) + .setVisibility(View.VISIBLE); + } + } + } + + @Override + public void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + /* + * Save to a file + */ + if (requestCode == CREATE_FILE_REQUEST_CODE && resultCode == Activity.RESULT_OK) { + if (data == null || data.getData() == null) { + Utils.showToastLong(StringRef.str("piko_pref_export_no_uri")); + return; + } + try (OutputStream outputStream = getContext().getContentResolver().openOutputStream(data.getData())) { + byte[] jsonStringByteArray = ImportExportLoginTokenPatch.createAccountJsonText(accountToSaveToFile).getBytes(); + outputStream.write(jsonStringByteArray); + Utils.showToastShort(StringRef.str("piko_pref_success")); + } catch (Exception e) { + Utils.showToastLong(StringRef.str("piko_pref_export_failed", StringRef.str("accounts_title"))); + app.morphe.extension.twitter.Utils.logger(e); + } + } + accountToSaveToFile = null; + } + + /** + * To show account names in spinner + */ + private static class AccountArrayAdapter extends ArrayAdapter { + public AccountArrayAdapter(@NonNull Context context, int resource, @NonNull Account[] accounts) { + super(context, resource, accounts); + } + + @NonNull + @Override + public View getView(int position, View convertView, @NonNull ViewGroup parent) { + TextView view = (TextView) super.getView(position, convertView, parent); + Account account = getItem(position); + view.setText(account.name); + return view; + } + + @Override + public View getDropDownView(int position, View convertView, @NonNull ViewGroup parent) { + TextView view = (TextView) super.getDropDownView(position, convertView, parent); + Account account = getItem(position); + view.setText(account.name); + return view; + } + } +} diff --git a/extensions/twitter/src/main/java/app/morphe/extension/twitter/patches/logintoken/ImportExportLoginTokenPatch.java b/extensions/twitter/src/main/java/app/morphe/extension/twitter/patches/logintoken/ImportExportLoginTokenPatch.java new file mode 100644 index 000000000..1c24a77e9 --- /dev/null +++ b/extensions/twitter/src/main/java/app/morphe/extension/twitter/patches/logintoken/ImportExportLoginTokenPatch.java @@ -0,0 +1,132 @@ +package app.morphe.extension.twitter.patches.logintoken; + +import android.accounts.Account; +import android.accounts.AccountManager; +import android.app.Activity; +import android.app.AlertDialog; +import android.content.Context; +import android.os.Bundle; +import android.view.View; +import android.widget.TextView; +import app.morphe.extension.shared.Logger; +import app.morphe.extension.shared.StringRef; +import app.morphe.extension.shared.Utils; +import org.json.JSONException; +import org.json.JSONObject; + +import java.util.Iterator; + +public class ImportExportLoginTokenPatch { + private static final String[] USER_DATA_KEYS = { + "account_user_id", + "account_state", + "account_field_version", + "account_user_type", + "account_settings", + "account_teams_contributor", + "account_teams_contributees", + "account_user_info", + "account_can_access_x_payments", + "account_is_x_payments_enrolled", + "com.twitter.android.oauth.token.teamsContributeeUserId" + }; + + /** + * Create a JSON text for export + */ + public static String createAccountJsonText(Account account) throws JSONException { + AccountManager accountManager = AccountManager.get(Utils.getContext()); + + JSONObject json = new JSONObject(); + json.put("username", account.name); + json.put("token", accountManager.peekAuthToken(account, "com.twitter.android.oauth.token")); + json.put("secret", accountManager.peekAuthToken(account, "com.twitter.android.oauth.token.secret")); + + JSONObject userData = new JSONObject(); + for (String key : USER_DATA_KEYS) { + String value = accountManager.getUserData(account, key); + if (value != null) { + userData.put(key, value); + } + } + + json.put("userdata", userData); + return json.toString(2); + } + + /** + * Add an account from imported JSON text + */ + public static void addAccount(Context context, String jsonText) { + try { + JSONObject accountJson = new JSONObject(jsonText); + + String userName = accountJson.optString("username"); + String token = accountJson.optString("token"); + String secret = accountJson.optString("secret"); + if (userName.isEmpty() || token.isEmpty() || secret.isEmpty()) { + Utils.showToastLong(StringRef.str("piko_login_token_import_failed_missing_info")); + return; + } + + Account account = new Account(userName, "com.twitter.android.auth.login"); + Bundle newUserData = new Bundle(); + JSONObject userData = accountJson.getJSONObject("userdata"); + Iterator itr = userData.keys(); + while (itr.hasNext()) { + String key = itr.next(); + newUserData.putString(key, userData.getString(key)); + } + + AccountManager accountManager = AccountManager.get(context); + boolean succeeded = accountManager.addAccountExplicitly(account, null, newUserData); + if (!succeeded) { + Utils.showToastLong(StringRef.str("piko_login_token_import_failed_already_exist")); + return; + } + + accountManager.setAuthToken(account, "com.twitter.android.oauth.token", token); + accountManager.setAuthToken(account, "com.twitter.android.oauth.token.secret", secret); + + // Show a dialog to prompt user to reopen the app. + // Closing the activity is enough to reflect the added account. + // Since the app begins some process immediately after an account is added, + // we do not use Utils.restartApp() which calls System.exit(0) for safety. + new AlertDialog.Builder(context) + .setTitle(StringRef.str("piko_pref_success")) + .setMessage(StringRef.str("piko_login_token_import_success_restart_required")) + .setPositiveButton(android.R.string.ok, (dialog, which) -> { + if (context instanceof Activity activity) + activity.finish(); + }) + .setNegativeButton(android.R.string.cancel, null) + .setCancelable(false) + .show(); + } catch (JSONException e) { + Utils.showToastLong("Failed to parse JSON: " + e.getMessage()); + } catch (Exception e) { + Logger.printException(() -> "addAccount failure", e); + } + } + + /* + * Injection point. + */ + @SuppressWarnings({"deprecation", "unused"}) + public static void initImportButton(View view) { + try { + TextView pikoTextView = view.findViewById(Utils.getResourceIdentifier(view.getContext(), "import_token_text", "id")); + pikoTextView.setTextColor(Utils.getResourceColor("twitter_blue")); + pikoTextView.setOnClickListener(v -> { + if (v.getContext() instanceof Activity activity) { + new ImportLoginTokenDialogFragment().show(activity.getFragmentManager(), null); + } else { + Logger.printException(() -> "Failed to open import dialog"); + } + }); + } catch (Exception e) { + Logger.printException(() -> "Failed to insert import token button", e); + } + } + +} diff --git a/extensions/twitter/src/main/java/app/morphe/extension/twitter/patches/logintoken/ImportLoginTokenDialogFragment.java b/extensions/twitter/src/main/java/app/morphe/extension/twitter/patches/logintoken/ImportLoginTokenDialogFragment.java new file mode 100644 index 000000000..3f2d7d5f1 --- /dev/null +++ b/extensions/twitter/src/main/java/app/morphe/extension/twitter/patches/logintoken/ImportLoginTokenDialogFragment.java @@ -0,0 +1,73 @@ +package app.morphe.extension.twitter.patches.logintoken; + +import android.app.Activity; +import android.app.AlertDialog; +import android.app.Dialog; +import android.app.DialogFragment; +import android.content.Intent; +import android.os.Bundle; +import app.morphe.extension.shared.Logger; +import app.morphe.extension.shared.StringRef; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; + +@SuppressWarnings("deprecation") +public class ImportLoginTokenDialogFragment extends DialogFragment { + private static final int PICK_FILE_REQUEST_CODE = 31; + + @Override + public Dialog onCreateDialog(Bundle savedInstanceState) { + String[] items = { + StringRef.str("piko_login_token_import_from_text"), + StringRef.str("piko_login_token_import_from_file") + }; + + AlertDialog dialog = new AlertDialog.Builder(getContext()) + .setItems(items, null) + .create(); + + // To prevent AlertDialog from closing automatically after pressing the "Import from file", set listener manually + dialog.getListView().setOnItemClickListener((parent, view, position, id) -> { + switch (position) { + case 0 -> { // Import from text + new ImportLoginTokenFromTextDialogFragment().show(getFragmentManager(), null); + dismiss(); + } + case 1 -> { // Import from file + Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT); + intent.addCategory(Intent.CATEGORY_OPENABLE); + intent.setType("application/json"); + startActivityForResult(intent, PICK_FILE_REQUEST_CODE); + } + } + }); + + return dialog; + } + + @Override + public void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + + // Import from file + if (requestCode == PICK_FILE_REQUEST_CODE && resultCode == Activity.RESULT_OK) { + if (data == null || data.getData() == null) { + StringRef.str("piko_pref_import_no_uri"); + return; + } + try (BufferedReader reader = new BufferedReader(new InputStreamReader(getContext().getContentResolver().openInputStream(data.getData())))) { + StringBuilder sb = new StringBuilder(); + String readString; + while ((readString = reader.readLine()) != null) { + sb.append(readString); + } + ImportExportLoginTokenPatch.addAccount(getContext(), sb.toString()); + dismiss(); + } catch (IOException e) { + Logger.printException(() -> "IOException while reading imported file", e); + } + } + } +} diff --git a/extensions/twitter/src/main/java/app/morphe/extension/twitter/patches/logintoken/ImportLoginTokenFromTextDialogFragment.java b/extensions/twitter/src/main/java/app/morphe/extension/twitter/patches/logintoken/ImportLoginTokenFromTextDialogFragment.java new file mode 100644 index 000000000..b3a23fd25 --- /dev/null +++ b/extensions/twitter/src/main/java/app/morphe/extension/twitter/patches/logintoken/ImportLoginTokenFromTextDialogFragment.java @@ -0,0 +1,25 @@ +package app.morphe.extension.twitter.patches.logintoken; + +import android.app.AlertDialog; +import android.app.Dialog; +import android.app.DialogFragment; +import android.os.Bundle; +import android.text.InputType; +import android.widget.EditText; + +@SuppressWarnings("deprecation") +public class ImportLoginTokenFromTextDialogFragment extends DialogFragment { + @Override + public Dialog onCreateDialog(Bundle savedInstanceState) { + EditText editText = new EditText(getContext()); + editText.setHint("json"); + editText.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_FLAG_MULTI_LINE); + return new AlertDialog.Builder(getContext()) + .setView(editText) + .setPositiveButton(android.R.string.ok, (dialog, id) -> + ImportExportLoginTokenPatch.addAccount(getContext(), editText.getText().toString())) + .setNegativeButton(android.R.string.cancel, null) + .create(); + + } +} diff --git a/extensions/twitter/src/main/java/app/morphe/extension/twitter/settings/ActivityHook.java b/extensions/twitter/src/main/java/app/morphe/extension/twitter/settings/ActivityHook.java index 8ae0a7c42..6e72f5939 100644 --- a/extensions/twitter/src/main/java/app/morphe/extension/twitter/settings/ActivityHook.java +++ b/extensions/twitter/src/main/java/app/morphe/extension/twitter/settings/ActivityHook.java @@ -122,10 +122,12 @@ private static String getTitle(String activity_name){ toolbarText = "piko_title_logging"; }else if (activity_name.equals(Settings.READER_MODE_KEY)) { toolbarText = "piko_title_native_reader_mode"; - }else if (activity_name.equals(Settings.READER_MODE_KEY)) { - toolbarText = "piko_title_native_reader_mode"; }else if (activity_name.equals(Settings.CHANGE_APP_ICON)) { toolbarText = "piko_pref_customisation_change_app_icon"; + }else if (activity_name.equals(Settings.EXPORT_LOGIN_TOKEN)) { + toolbarText = "piko_pref_export_login_token"; + }else if (activity_name.equals(Settings.FORCE_REMOVE_ACCOUNT)) { + toolbarText = "piko_pref_force_remove_account"; } return Utils.getResourceString(toolbarText); } diff --git a/extensions/twitter/src/main/java/app/morphe/extension/twitter/settings/ScreenBuilder.java b/extensions/twitter/src/main/java/app/morphe/extension/twitter/settings/ScreenBuilder.java index 8b3b52acf..3ddeacf2c 100644 --- a/extensions/twitter/src/main/java/app/morphe/extension/twitter/settings/ScreenBuilder.java +++ b/extensions/twitter/src/main/java/app/morphe/extension/twitter/settings/ScreenBuilder.java @@ -1029,6 +1029,26 @@ public void buildExportSection(boolean buildCategory){ ) ); + if (SettingsStatus.exportLoginToken) { + addPreference(category, + helper.buttonPreference( + StringRef.str("piko_pref_export_login_token"), + "", + Settings.EXPORT_LOGIN_TOKEN, + "ic_vector_passkey", + null + ) + ); + addPreference(category, + helper.buttonPreference( + StringRef.str("piko_pref_force_remove_account"), + StringRef.str("piko_pref_force_remove_account_desc"), + Settings.FORCE_REMOVE_ACCOUNT, + "ic_vector_passkey", + null + ) + ); + } } public void buildPikoSection(boolean buildCategory){ diff --git a/extensions/twitter/src/main/java/app/morphe/extension/twitter/settings/Settings.java b/extensions/twitter/src/main/java/app/morphe/extension/twitter/settings/Settings.java index 4d84b8c5e..dda198609 100644 --- a/extensions/twitter/src/main/java/app/morphe/extension/twitter/settings/Settings.java +++ b/extensions/twitter/src/main/java/app/morphe/extension/twitter/settings/Settings.java @@ -125,7 +125,8 @@ public class Settings extends BaseSettings { public static final String DELETE_EMOJI_FONT = "delete_emoji_font"; public static final String RESET_READER_MODE_CACHE = "reader_mode_cache"; public static final String CHANGE_APP_ICON = "change_app_icon"; - + public static final String EXPORT_LOGIN_TOKEN = "export_login_token"; + public static final String FORCE_REMOVE_ACCOUNT = "force_remove_account"; public static final String PREMIUM_SECTION = "premium_section"; public static final String DOWNLOAD_SECTION = "download_section"; public static final String FLAGS_SECTION = "flags_section"; diff --git a/extensions/twitter/src/main/java/app/morphe/extension/twitter/settings/SettingsStatus.java b/extensions/twitter/src/main/java/app/morphe/extension/twitter/settings/SettingsStatus.java index f0203d9e5..cdd19af0f 100644 --- a/extensions/twitter/src/main/java/app/morphe/extension/twitter/settings/SettingsStatus.java +++ b/extensions/twitter/src/main/java/app/morphe/extension/twitter/settings/SettingsStatus.java @@ -375,6 +375,11 @@ public static void disUnifyXChatSystem() { disUnifyXChatSystem = true; } + public static boolean exportLoginToken = false; + public static void exportLoginToken() { + exportLoginToken = true; + } + public static boolean enableTimelineSection() { return ( hidePostMetrics || hideNavbarBadge || showSourceLabel || hideCommBadge || showSensitiveMedia || hideNudgeButton || disableAutoTimelineScroll || forceTranslate || hidePromoteButton || hideCommunityNote || hideLiveThreads || hideBanner || hideInlineBmk || showPollResultsEnabled || hideImmersivePlayer || enableVidAutoAdvance || enableForceHD); } diff --git a/extensions/twitter/src/main/java/app/morphe/extension/twitter/settings/fragments/SettingsAboutFragment.java b/extensions/twitter/src/main/java/app/morphe/extension/twitter/settings/fragments/SettingsAboutFragment.java index cd2ba7786..cd305a176 100644 --- a/extensions/twitter/src/main/java/app/morphe/extension/twitter/settings/fragments/SettingsAboutFragment.java +++ b/extensions/twitter/src/main/java/app/morphe/extension/twitter/settings/fragments/SettingsAboutFragment.java @@ -144,6 +144,7 @@ public void onCreate(@org.jetbrains.annotations.Nullable Bundle savedInstanceSta flags.put(strRes("piko_pref_hide_badge_nav_bar"),SettingsStatus.hideNavbarBadge); flags.put(strRes("piko_pref_hide_post_inline_metrics"),SettingsStatus.hidePostMetrics); flags.put(strRes("piko_disunify_xchat_system"),SettingsStatus.disUnifyXChatSystem); + flags.put(strRes("piko_pref_export_login_token"),SettingsStatus.exportLoginToken); LegacyTwitterPreferenceCategory patPref = preferenceCategory(strRes("piko_pref_patches"), screen); diff --git a/extensions/twitter/src/main/java/app/morphe/extension/twitter/settings/widgets/ButtonPref.java b/extensions/twitter/src/main/java/app/morphe/extension/twitter/settings/widgets/ButtonPref.java index b79586dfd..c277ff3a7 100644 --- a/extensions/twitter/src/main/java/app/morphe/extension/twitter/settings/widgets/ButtonPref.java +++ b/extensions/twitter/src/main/java/app/morphe/extension/twitter/settings/widgets/ButtonPref.java @@ -23,6 +23,7 @@ import android.util.AttributeSet; import app.morphe.extension.twitter.Utils; import app.morphe.extension.twitter.patches.DatabasePatch; +import app.morphe.extension.twitter.patches.logintoken.ExportLoginTokenFragment; import app.morphe.extension.twitter.settings.ActivityHook; import app.morphe.extension.twitter.settings.Settings; import app.morphe.extension.twitter.settings.fragments.BackupPrefFragment; @@ -127,6 +128,12 @@ public boolean onPreferenceClick(Preference preference) { ReaderModeUtils.clearCache(); } else if (key.equals(Settings.CHANGE_APP_ICON)) { fragment = new IconSelectorFragment(); + } else if (key.equals(Settings.EXPORT_LOGIN_TOKEN)) { + bundle.putBoolean("isRemoveAccount", false); + fragment = new ExportLoginTokenFragment(); + } else if (key.equals(Settings.FORCE_REMOVE_ACCOUNT)) { + bundle.putBoolean("isRemoveAccount", true); + fragment = new ExportLoginTokenFragment(); } else { ActivityHook.startActivity(key); } diff --git a/patches/src/main/kotlin/app/crimera/patches/twitter/misc/login/ImportExportLoginTokenPatch.kt b/patches/src/main/kotlin/app/crimera/patches/twitter/misc/login/ImportExportLoginTokenPatch.kt new file mode 100644 index 000000000..d0e115dc1 --- /dev/null +++ b/patches/src/main/kotlin/app/crimera/patches/twitter/misc/login/ImportExportLoginTokenPatch.kt @@ -0,0 +1,90 @@ +package app.crimera.patches.twitter.misc.login + +import app.crimera.patches.twitter.misc.settings.SettingsStatusLoadFingerprint +import app.crimera.patches.twitter.misc.settings.settingsPatch +import app.crimera.utils.Constants.PATCHES_DESCRIPTOR +import app.crimera.utils.enableSettings +import app.morphe.patcher.Fingerprint +import app.morphe.patcher.InstructionLocation +import app.morphe.patcher.extensions.InstructionExtensions.addInstructions +import app.morphe.patcher.extensions.InstructionExtensions.getInstruction +import app.morphe.patcher.literal +import app.morphe.patcher.methodCall +import app.morphe.patcher.opcode +import app.morphe.patcher.patch.bytecodePatch +import app.morphe.patcher.patch.resourcePatch +import app.morphe.shared.misc.mapping.ResourceType +import app.morphe.shared.misc.mapping.getResourceId +import app.morphe.shared.misc.mapping.resourceMappingPatch +import app.morphe.util.ResourceGroup +import app.morphe.util.copyResources +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction + +private const val EXTENSIONS_CLASS_DESCRIPTOR = "$PATCHES_DESCRIPTOR/logintoken/ImportExportLoginTokenPatch;" + +private object OcfCtaStepDynamicLayoutInflateFingerprint : Fingerprint( + definingClass = "Lcom/twitter/onboarding/ocf/common/", + accessFlags = listOf(AccessFlags.PUBLIC, AccessFlags.CONSTRUCTOR), + returnType = "V", + filters = listOf( + literal(getResourceId(ResourceType.LAYOUT, "ocf_cta_step_dynamic")), + methodCall(definingClass = "Landroid/view/LayoutInflater;", name = "inflate"), + opcode(Opcode.MOVE_RESULT_OBJECT, InstructionLocation.MatchAfterImmediately()) + ) +) + +@Suppress("unused") +val importExportLoginTokenPatch = bytecodePatch( + name = "Import/Export login token", + description = "Adds an feature to export and import the token of accounts. " + + "This is useful when logging in on your second device or when re-installing piko.", +) { + compatibleWith("com.twitter.android") + + dependsOn( + resourceMappingPatch, + settingsPatch, + resourcePatch { + execute { + document("res/layout/ocf_cta_step_dynamic.xml").use { + val newElement = it.createElement("com.twitter.ui.components.text.legacy.TypefacesTextView").apply { + setAttribute("android:id", "@+id/import_token_text") + setAttribute("android:text", "@string/piko_login_token_import_token_button_text") + setAttribute("android:gravity", "center_vertical") + setAttribute("android:layout_width", "match_parent") + setAttribute("android:layout_height", "wrap_content") + setAttribute("android:layout_marginBottom", "@dimen/ocf_standard_spacing") + setAttribute("android:layout_marginHorizontal", "@dimen/ocf_screen_padding_wide") + setAttribute("android:clickable", "true") + setAttribute("style", "@style/OcfBodyText") + } + it.documentElement.appendChild(newElement) + } + + copyResources( + "twitter/settings", + ResourceGroup("layout", "fragment_export_token.xml") + ) + } + } + ) + + execute { + OcfCtaStepDynamicLayoutInflateFingerprint.let { + it.method.apply { + val targetIndex = it.instructionMatches.last().index + val targetRegister = getInstruction(targetIndex).registerA + addInstructions( + targetIndex + 1, + """ + invoke-static {v$targetRegister}, $EXTENSIONS_CLASS_DESCRIPTOR->initImportButton(Landroid/view/View;)V + """ + ) + } + } + + SettingsStatusLoadFingerprint.enableSettings("exportLoginToken") + } +} diff --git a/patches/src/main/resources/addresources/values/twitter/strings.xml b/patches/src/main/resources/addresources/values/twitter/strings.xml index 2069761c5..74dcc3302 100644 --- a/patches/src/main/resources/addresources/values/twitter/strings.xml +++ b/patches/src/main/resources/addresources/values/twitter/strings.xml @@ -200,6 +200,9 @@ Error: Importing %s failed Error: No destination provided Error: No file provided + Export login token + Force remove account + Removes an account from the app without uninstalling or logging out About @@ -347,4 +350,43 @@ Tropical Teal Vivid Orange + + "About this feature\n +\n +Here you can export the login token for the account you\'re currently logged in to piko.\n +This is useful in the following cases:\n +- If you already have a device logged in and want to log in on another device\n +- If you want to reinstall Piko\n +- If you want to log into a cloned APK\n +- If you have another rooted device and can log in normally\n +Tokens will be exported in JSON format. It can be used in the login screen. To import additional accounts, select \"Create a new account\" instead of \"Add an existing account\".\n +\n +How it works\n +\n +X for Android stores the tokens and information of the currently logged-in account in the Android system\'s AccountManager. +This information continues to work even if you clear the app data. +This is why X still be logged in even after clearing the app data.\n +Therefore, you can restore this information and log in on other devices.\n +\n +WARNING\n +\n +Exported tokens are highly confidential information. Never share it with anyone! If a third party obtains this token, they can freely access your account until you log out. \n +Please also be careful of your clipboard or exported files." + Select an account: + Copy to clipboard + Save to file + Login through token json + Import from text + Import from file + Please restart the app + Error: Missing username or token or secret + Failed to add account (may already exist) + Remove account + "Removes an account from the app without uninstalling the app, or logging out the session token. +When you perform this, a notification \"You were logged out due to an error.\" will appear. This is expected behavior, so ignore the error. + +If you uninstall the app, you do not need to use this feature because the accounts in the app will not be logged out." + Hint: It looks like you only have one account logged in, so you can simply reinstall the app to remove the account without logging out. + Are you sure you want to remove this account from the app? Make sure you have exported the token or imported it to another app. + diff --git a/patches/src/main/resources/twitter/settings/layout/fragment_export_token.xml b/patches/src/main/resources/twitter/settings/layout/fragment_export_token.xml new file mode 100644 index 000000000..5c8539aea --- /dev/null +++ b/patches/src/main/resources/twitter/settings/layout/fragment_export_token.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + +