Skip to content
Draft
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
@@ -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<Account> {
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;
}
}
}
Original file line number Diff line number Diff line change
@@ -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<String> 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);
}
}

}
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
}
Loading