From 995f47870895cc2ed0ce6eca1b471e3932b62be5 Mon Sep 17 00:00:00 2001 From: sk Date: Sat, 3 Jun 2023 20:31:00 +0200 Subject: [PATCH] allow sharing @-handles with megalodon closes sk22#540 --- .../android/ui/utils/UiUtilsTest.java | 101 ++++++++++++++++++ .../android/ExternalShareActivity.java | 29 +++-- .../android/ui/AccountSwitcherSheet.java | 10 +- .../android/ui/utils/UiUtils.java | 77 ++++++++++++- mastodon/src/main/res/values/strings_sk.xml | 1 + 5 files changed, 203 insertions(+), 15 deletions(-) create mode 100644 mastodon/src/androidTest/java/org/joinmastodon/android/ui/utils/UiUtilsTest.java diff --git a/mastodon/src/androidTest/java/org/joinmastodon/android/ui/utils/UiUtilsTest.java b/mastodon/src/androidTest/java/org/joinmastodon/android/ui/utils/UiUtilsTest.java new file mode 100644 index 000000000..2fd84eb77 --- /dev/null +++ b/mastodon/src/androidTest/java/org/joinmastodon/android/ui/utils/UiUtilsTest.java @@ -0,0 +1,101 @@ +package org.joinmastodon.android.ui.utils; + +import static org.junit.Assert.*; + +import android.util.Pair; + +import org.joinmastodon.android.api.session.AccountSessionManager; +import org.joinmastodon.android.model.Account; +import org.joinmastodon.android.model.Instance; +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.Test; + +import java.util.Optional; + +public class UiUtilsTest { + @BeforeClass + public static void createDummySession() { + Instance dummyInstance = new Instance(); + dummyInstance.uri = "test.tld"; + Account dummyAccount = new Account(); + dummyAccount.id = "123456"; + AccountSessionManager.getInstance().addAccount(dummyInstance, null, dummyAccount, null, null); + } + + @AfterClass + public static void cleanUp() { + AccountSessionManager.getInstance().removeAccount("test.tld_123456"); + } + + @Test + public void looksLikeFediverseHandle() { + assertEquals( + Optional.of(Pair.create("megalodon", Optional.of("floss.social"))), + UiUtils.looksLikeFediverseHandle("megalodon@floss.social") + ); + + assertEquals( + Optional.of(Pair.create("megalodon", Optional.of("floss.social"))), + UiUtils.looksLikeFediverseHandle("@megalodon@floss.social") + ); + + assertEquals( + Optional.of(Pair.create("megalodon", Optional.empty())), + UiUtils.looksLikeFediverseHandle("@megalodon") + ); + + assertEquals( + Optional.empty(), + UiUtils.looksLikeFediverseHandle("megalodon") + ); + + assertEquals( + Optional.empty(), + UiUtils.looksLikeFediverseHandle("this is not a fedi handle") + ); + + assertEquals( + Optional.empty(), + UiUtils.looksLikeFediverseHandle("not@a-domain") + ); + } + + @Test + public void acctMatches() { + assertTrue("local account, domain not specified", UiUtils.acctMatches( + "test.tld_123456", + "someone", + "someone", + null + )); + + assertTrue("domain not specified", UiUtils.acctMatches( + "test.tld_123456", + "someone@somewhere.social", + "someone", + null + )); + + assertTrue("local account, domain specified, different casing", UiUtils.acctMatches( + "test.tld_123456", + "SomeOne", + "someone", + "Test.TLD" + )); + + assertFalse("username doesn't match", UiUtils.acctMatches( + "test.tld_123456", + "someone-else@somewhere.social", + "someone", + "somewhere.social" + )); + + assertFalse("domain doesn't match", UiUtils.acctMatches( + "test.tld_123456", + "someone@somewhere.social", + "someone", + "somewhere.else" + )); + } +} \ No newline at end of file diff --git a/mastodon/src/main/java/org/joinmastodon/android/ExternalShareActivity.java b/mastodon/src/main/java/org/joinmastodon/android/ExternalShareActivity.java index be1657660..d1ad5468b 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ExternalShareActivity.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ExternalShareActivity.java @@ -6,6 +6,7 @@ import android.content.Intent; import android.net.Uri; import android.os.Bundle; import android.text.TextUtils; +import android.util.Pair; import android.widget.Toast; import org.joinmastodon.android.api.session.AccountSession; @@ -19,6 +20,7 @@ import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Optional; +import java.util.function.BiConsumer; import androidx.annotation.Nullable; import me.grishka.appkit.FragmentStackActivity; @@ -31,19 +33,23 @@ public class ExternalShareActivity extends FragmentStackActivity{ if(savedInstanceState==null){ Optional text = Optional.ofNullable(getIntent().getStringExtra(Intent.EXTRA_TEXT)); - boolean isMastodonURL = text.map(UiUtils::looksLikeMastodonUrl).orElse(false); + Optional>> fediHandle = text.flatMap(UiUtils::looksLikeFediverseHandle); + boolean isFediUrl = text.map(UiUtils::looksLikeFediverseUrl).orElse(false); + boolean isOpenable = isFediUrl || fediHandle.isPresent(); List sessions=AccountSessionManager.getInstance().getLoggedInAccounts(); - if(sessions.isEmpty()){ + if (sessions.isEmpty()){ Toast.makeText(this, R.string.err_not_logged_in, Toast.LENGTH_SHORT).show(); finish(); - }else if(sessions.size()==1 && !isMastodonURL){ - openComposeFragment(sessions.get(0).getID()); - }else{ - new AccountSwitcherSheet(this, null, true, isMastodonURL, (accountId, open) -> { + } else if (isOpenable || sessions.size() > 1) { + AccountSwitcherSheet sheet = new AccountSwitcherSheet(this, null, true, isOpenable); + if (isOpenable) sheet.setOnClick((accountId, open) -> { if (open && text.isPresent()) { - UiUtils.lookupURL(this, accountId, text.get(), false, (clazz, args) -> { + BiConsumer, Bundle> callback = (clazz, args) -> { if (clazz == null) { + Toast.makeText(this, R.string.sk_open_in_app_failed, Toast.LENGTH_SHORT).show(); + // TODO: do something about the window getting leaked + sheet.dismiss(); finish(); return; } @@ -52,11 +58,16 @@ public class ExternalShareActivity extends FragmentStackActivity{ intent.putExtras(args); finish(); startActivity(intent); - }); + }; + if (isFediUrl) UiUtils.lookupURL(this, accountId, text.get(), false, callback); + else UiUtils.lookupAccountHandle(this, accountId, fediHandle.get(), callback); } else { openComposeFragment(accountId); } - }).show(); + }); + sheet.show(); + } else if (sessions.size() == 1) { + openComposeFragment(sessions.get(0).getID()); } } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/AccountSwitcherSheet.java b/mastodon/src/main/java/org/joinmastodon/android/ui/AccountSwitcherSheet.java index 0eb9f205d..e6cc268ab 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/AccountSwitcherSheet.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/AccountSwitcherSheet.java @@ -58,18 +58,18 @@ import me.grishka.appkit.views.UsableRecyclerView; public class AccountSwitcherSheet extends BottomSheet{ private final Activity activity; private final HomeFragment fragment; - private final BiConsumer onClick; private final boolean externalShare, openInApp; + private BiConsumer onClick; private UsableRecyclerView list; private List accounts; private ListImageLoaderWrapper imgLoader; private AccountsAdapter accountsAdapter; public AccountSwitcherSheet(@NonNull Activity activity, @Nullable HomeFragment fragment){ - this(activity, fragment, false, false, null); + this(activity, fragment, false, false); } - public AccountSwitcherSheet(@NonNull Activity activity, @Nullable HomeFragment fragment, boolean externalShare, boolean openInApp, BiConsumer onClick){ + public AccountSwitcherSheet(@NonNull Activity activity, @Nullable HomeFragment fragment, boolean externalShare, boolean openInApp){ super(activity); this.activity=activity; this.fragment=fragment; @@ -123,6 +123,10 @@ public class AccountSwitcherSheet extends BottomSheet{ UiUtils.getThemeColor(activity, R.attr.colorM3Primary), 0.05f)), !UiUtils.isDarkTheme()); } + public void setOnClick(BiConsumer onClick) { + this.onClick = onClick; + } + private void confirmLogOut(String accountID){ AccountSession session=AccountSessionManager.getInstance().getAccount(accountID); new M3AlertDialogBuilder(activity) diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/utils/UiUtils.java b/mastodon/src/main/java/org/joinmastodon/android/ui/utils/UiUtils.java index f2b3f1287..01bc646bd 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/utils/UiUtils.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/utils/UiUtils.java @@ -37,6 +37,7 @@ import android.text.SpannableStringBuilder; import android.text.Spanned; import android.text.TextUtils; import android.util.Log; +import android.util.Pair; import android.view.HapticFeedbackConstants; import android.view.Menu; import android.view.MenuItem; @@ -98,9 +99,9 @@ import org.parceler.Parcels; import java.io.File; import java.lang.reflect.Field; import java.lang.reflect.Method; +import java.net.IDN; import java.net.URI; import java.net.URISyntaxException; -import java.net.URL; import java.time.Instant; import java.time.ZoneId; import java.time.ZonedDateTime; @@ -905,6 +906,29 @@ public class UiUtils { return theme == GlobalUserPreferences.ThemePreference.DARK; } + public static Optional>> looksLikeFediverseHandle(String maybeFediHandle) { + // https://stackoverflow.com/a/26987741, except i put a + here ... v + String domainRegex = "^(((?!-))(xn--|_)?[a-z0-9-]{0,61}[a-z0-9]\\.)+(xn--)?([a-z0-9][a-z0-9\\-]{0,60}|[a-z0-9-]{1,30}\\.[a-z]{2,})$"; + try { + List parts = Arrays.stream(maybeFediHandle.split("@")) + .filter(part -> !part.isEmpty()) + .collect(Collectors.toList()); + if (parts.size() == 0 || !parts.get(0).matches("^[^/\\s]+$")) { + return Optional.empty(); + } else if (parts.size() == 2) { + String domain = IDN.toASCII(parts.get(1)); + if (!domain.matches(domainRegex)) return Optional.empty(); + return Optional.of(Pair.create(parts.get(0), Optional.of(parts.get(1)))); + } else if (maybeFediHandle.startsWith("@")) { + return Optional.of(Pair.create(parts.get(0), Optional.empty())); + } else { + return Optional.empty(); + } + } catch (IllegalArgumentException ignored) { + return Optional.empty(); + } + } + // https://mastodon.foo.bar/@User // https://mastodon.foo.bar/@User/43456787654678 // https://pleroma.foo.bar/users/User @@ -921,7 +945,7 @@ public class UiUtils { // https://foo.microblog.pub/o/5b64045effd24f48a27d7059f6cb38f5 // // COPIED FROM https://github.com/tuskyapp/Tusky/blob/develop/app/src/main/java/com/keylesspalace/tusky/util/LinkHelper.kt - public static boolean looksLikeMastodonUrl(String urlString) { + public static boolean looksLikeFediverseUrl(String urlString) { URI uri; try { uri = new URI(urlString); @@ -1088,6 +1112,53 @@ public class UiUtils { }); } + public static boolean acctMatches(String accountID, String acct, String queriedUsername, @Nullable String queriedDomain) { + // check if the username matches + if (!acct.split("@")[0].equalsIgnoreCase(queriedUsername)) return false; + + boolean resultOnHomeInstance = !acct.contains("@"); + if (resultOnHomeInstance) { + // acct is formatted like 'someone' + // only allow home instance result if query didn't specify a domain, + // or the specified domain does, in fact, match the account session's domain + AccountSession session = AccountSessionManager.getInstance().getAccount(accountID); + return queriedDomain == null || session.domain.equalsIgnoreCase(queriedDomain); + } else if (queriedDomain == null) { + // accept whatever result we have as there's no queried domain to compare to + return true; + } else { + // acct is formatted like 'someone@somewhere' + return acct.split("@")[1].equalsIgnoreCase(queriedDomain); + } + } + + public static void lookupAccountHandle(Context context, String accountID, Pair> queryHandle, BiConsumer, Bundle> go) { + String fullHandle = ("@" + queryHandle.first) + (queryHandle.second.map(domain -> "@" + domain).orElse("")); + new GetSearchResults(fullHandle, GetSearchResults.Type.ACCOUNTS, true) + .setCallback(new Callback<>() { + @Override + public void onSuccess(SearchResults results) { + Bundle args = new Bundle(); + args.putString("account", accountID); + Optional account = results.accounts.stream() + .filter(a -> acctMatches(accountID, a.acct, queryHandle.first, queryHandle.second.orElse(null))) + .findAny(); + if (account.isPresent()) { + args.putParcelable("profileAccount", Parcels.wrap(account.get())); + go.accept(ProfileFragment.class, args); + return; + } + Toast.makeText(context, R.string.sk_resource_not_found, Toast.LENGTH_SHORT).show(); + go.accept(null, null); + } + + @Override + public void onError(ErrorResponse error) { + + } + }).exec(accountID); + } + public static void lookupURL(Context context, String accountID, String url, boolean launchBrowser, BiConsumer, Bundle> go) { Uri uri = Uri.parse(url); List path = uri.getPathSegments(); @@ -1114,7 +1185,7 @@ public class UiUtils { d -> transformDialogForLookup(context, accountID, url, d)) .exec(accountID); return; - } else if (looksLikeMastodonUrl(url)) { + } else if (looksLikeFediverseUrl(url)) { new GetSearchResults(url, null, true) .setCallback(new Callback<>() { @Override diff --git a/mastodon/src/main/res/values/strings_sk.xml b/mastodon/src/main/res/values/strings_sk.xml index d8900891b..4ccd38655 100644 --- a/mastodon/src/main/res/values/strings_sk.xml +++ b/mastodon/src/main/res/values/strings_sk.xml @@ -290,6 +290,7 @@ This lets you have a content type be pre-selected when creating new posts, overriding the value set in “Posting preferences”. Instance info temporarily unavailable Open in app + Could not open in app Share with account Share or open with account \ No newline at end of file