diff --git a/app/src/main/java/app/fedilab/android/helper/CrossActionHelper.java b/app/src/main/java/app/fedilab/android/helper/CrossActionHelper.java index cb361281..4c6e3be1 100644 --- a/app/src/main/java/app/fedilab/android/helper/CrossActionHelper.java +++ b/app/src/main/java/app/fedilab/android/helper/CrossActionHelper.java @@ -27,13 +27,18 @@ import androidx.lifecycle.ViewModelProvider; import androidx.lifecycle.ViewModelStoreOwner; import androidx.preference.PreferenceManager; +import java.io.IOException; import java.util.ArrayList; import java.util.List; +import java.util.concurrent.TimeUnit; import app.fedilab.android.BaseMainActivity; import app.fedilab.android.R; import app.fedilab.android.activities.ComposeActivity; +import app.fedilab.android.activities.MainActivity; import app.fedilab.android.client.entities.Account; +import app.fedilab.android.client.mastodon.MastodonSearchService; +import app.fedilab.android.client.mastodon.entities.Results; import app.fedilab.android.client.mastodon.entities.Status; import app.fedilab.android.exception.DBException; import app.fedilab.android.ui.drawer.AccountsSearchAdapter; @@ -41,6 +46,11 @@ import app.fedilab.android.viewmodel.mastodon.AccountsVM; import app.fedilab.android.viewmodel.mastodon.SearchVM; import app.fedilab.android.viewmodel.mastodon.StatusesVM; import es.dmoral.toasty.Toasty; +import okhttp3.OkHttpClient; +import retrofit2.Call; +import retrofit2.Response; +import retrofit2.Retrofit; +import retrofit2.converter.gson.GsonConverterFactory; public class CrossActionHelper { @@ -235,6 +245,122 @@ public class CrossActionHelper { } } + + private static MastodonSearchService init(Context context, @NonNull String instance) { + final OkHttpClient okHttpClient = new OkHttpClient.Builder() + .readTimeout(60, TimeUnit.SECONDS) + .connectTimeout(60, TimeUnit.SECONDS) + .proxy(Helper.getProxy(context)) + .build(); + Retrofit retrofit = new Retrofit.Builder() + .baseUrl("https://" + instance + "/api/v2/") + .addConverterFactory(GsonConverterFactory.create()) + .client(okHttpClient) + .build(); + return retrofit.create(MastodonSearchService.class); + } + + /** + * Fetch and federate the remote status + */ + public static void fetchRemoteStatus(@NonNull Context context, @NonNull Account ownerAccount, Status targetedStatus, Callback callback) { + MastodonSearchService mastodonSearchService = init(context, MainActivity.currentInstance); + new Thread(() -> { + Call resultsCall = mastodonSearchService.search(ownerAccount.token, targetedStatus.url, null, "statuses", false, true, false, 0, null, null, 1); + Results results = null; + if (resultsCall != null) { + try { + Response resultsResponse = resultsCall.execute(); + if (resultsResponse.isSuccessful()) { + results = resultsResponse.body(); + if (results != null) { + if (results.statuses == null) { + results.statuses = new ArrayList<>(); + } else { + results.statuses = SpannableHelper.convertStatus(context, results.statuses); + } + if (results.accounts == null) { + results.accounts = new ArrayList<>(); + } + if (results.hashtags == null) { + results.hashtags = new ArrayList<>(); + } + } + } + } catch (IOException e) { + e.printStackTrace(); + } + } + Handler mainHandler = new Handler(Looper.getMainLooper()); + Results finalResults = results; + Runnable myRunnable = () -> { + if (finalResults != null && finalResults.statuses != null && finalResults.statuses.size() > 0) { + callback.federatedStatus(finalResults.statuses.get(0)); + } + }; + mainHandler.post(myRunnable); + + }).start(); + } + + /** + * Fetch and federate the remote status + */ + public static void fetchRemoteAccount(@NonNull Context context, @NonNull Account ownerAccount, app.fedilab.android.client.mastodon.entities.Account targetedAccount, Callback callback) { + + + MastodonSearchService mastodonSearchService = init(context, MainActivity.currentInstance); + String search; + if (targetedAccount.acct.contains("@")) { //Not from same instance + search = targetedAccount.acct; + } else { + search = targetedAccount.acct + "@" + BaseMainActivity.currentInstance; + } + new Thread(() -> { + Call resultsCall = mastodonSearchService.search(ownerAccount.token, search, null, "accounts", false, true, false, 0, null, null, 1); + Results results = null; + if (resultsCall != null) { + try { + Response resultsResponse = resultsCall.execute(); + if (resultsResponse.isSuccessful()) { + results = resultsResponse.body(); + if (results != null) { + if (results.statuses == null) { + results.statuses = new ArrayList<>(); + } else { + results.statuses = SpannableHelper.convertStatus(context, results.statuses); + } + if (results.accounts == null) { + results.accounts = new ArrayList<>(); + } + if (results.hashtags == null) { + results.hashtags = new ArrayList<>(); + } + } + } + } catch (IOException e) { + e.printStackTrace(); + } + } + Handler mainHandler = new Handler(Looper.getMainLooper()); + Results finalResults = results; + Runnable myRunnable = () -> { + if (finalResults != null && finalResults.accounts != null && finalResults.accounts.size() > 0) { + callback.federatedAccount(finalResults.accounts.get(0)); + } + }; + mainHandler.post(myRunnable); + + }).start(); + } + + public interface Callback { + void federatedStatus(Status status); + + void federatedAccount(app.fedilab.android.client.mastodon.entities.Account account); + } + + public enum TypeOfCrossAction { FOLLOW_ACTION, UNFOLLOW_ACTION, diff --git a/app/src/main/java/app/fedilab/android/helper/Helper.java b/app/src/main/java/app/fedilab/android/helper/Helper.java index 14e1540b..6cceae7b 100644 --- a/app/src/main/java/app/fedilab/android/helper/Helper.java +++ b/app/src/main/java/app/fedilab/android/helper/Helper.java @@ -270,7 +270,10 @@ public class Helper { public static final Pattern mediumPattern = Pattern.compile("([\\w@-]*)?\\.?medium.com/@?([/\\w-]+)"); public static final Pattern wikipediaPattern = Pattern.compile("([\\w_-]+)\\.wikipedia.org/(((?!([\"'<])).)*)"); public static final Pattern codePattern = Pattern.compile("code=([\\w-]+)"); + public static final Pattern urlPattern = Pattern.compile( + "(?i)\\b((?:[a-z][\\w-]+:(?:/{1,3}|[a-z0-9%])|www\\d{0,3}[.]|[a-z0-9.\\-]+[.][a-z]{2,10}/)(?:[^\\s()<>]+|\\(([^\\s()<>]+|(\\([^\\s()<>]+\\)))*\\))+(?:\\(([^\\s()<>]+|(\\([^\\s()<>]+\\)))*\\)|[^\\s`!()\\[\\]{};:'\".,<>?«»“”‘’]))", + Pattern.CASE_INSENSITIVE | Pattern.MULTILINE | Pattern.DOTALL); /* * List from ClearUrls * https://gitlab.com/KevinRoebert/ClearUrls/blob/master/data/data.min.json#L106 diff --git a/app/src/main/java/app/fedilab/android/helper/LongClickLinkMovementMethod.java b/app/src/main/java/app/fedilab/android/helper/LongClickLinkMovementMethod.java new file mode 100644 index 00000000..708384d1 --- /dev/null +++ b/app/src/main/java/app/fedilab/android/helper/LongClickLinkMovementMethod.java @@ -0,0 +1,82 @@ +package app.fedilab.android.helper; + +import android.os.Handler; +import android.text.Layout; +import android.text.Selection; +import android.text.Spannable; +import android.text.method.LinkMovementMethod; +import android.text.method.MovementMethod; +import android.view.MotionEvent; +import android.widget.TextView; + + +//https://stackoverflow.com/a/20435892 +public class LongClickLinkMovementMethod extends LinkMovementMethod { + + private static LongClickLinkMovementMethod sInstance; + private Handler mLongClickHandler; + private boolean mIsLongPressed = false; + + public static MovementMethod getInstance() { + if (sInstance == null) { + sInstance = new LongClickLinkMovementMethod(); + sInstance.mLongClickHandler = new Handler(); + } + + return sInstance; + } + + @Override + public boolean onTouchEvent(final TextView widget, Spannable buffer, + MotionEvent event) { + int action = event.getAction(); + if (action == MotionEvent.ACTION_CANCEL) { + if (mLongClickHandler != null) { + mLongClickHandler.removeCallbacksAndMessages(null); + } + } + + if (action == MotionEvent.ACTION_UP || + action == MotionEvent.ACTION_DOWN) { + int x = (int) event.getX(); + int y = (int) event.getY(); + + x -= widget.getTotalPaddingLeft(); + y -= widget.getTotalPaddingTop(); + + x += widget.getScrollX(); + y += widget.getScrollY(); + + Layout layout = widget.getLayout(); + int line = layout.getLineForVertical(y); + int off = layout.getOffsetForHorizontal(line, x); + + final LongClickableSpan[] link = buffer.getSpans(off, off, LongClickableSpan.class); + + if (link.length != 0) { + if (action == MotionEvent.ACTION_UP) { + if (mLongClickHandler != null) { + mLongClickHandler.removeCallbacksAndMessages(null); + } + if (!mIsLongPressed) { + link[0].onClick(widget); + } + mIsLongPressed = false; + } else { + Selection.setSelection(buffer, + buffer.getSpanStart(link[0]), + buffer.getSpanEnd(link[0])); + int LONG_CLICK_TIME = 1000; + mLongClickHandler.postDelayed(() -> { + link[0].onLongClick(widget); + mIsLongPressed = true; + widget.invalidate(); + }, LONG_CLICK_TIME); + } + return true; + } + } + + return super.onTouchEvent(widget, buffer, event); + } +} \ No newline at end of file diff --git a/app/src/main/java/app/fedilab/android/helper/LongClickableSpan.java b/app/src/main/java/app/fedilab/android/helper/LongClickableSpan.java new file mode 100644 index 00000000..9d518ec9 --- /dev/null +++ b/app/src/main/java/app/fedilab/android/helper/LongClickableSpan.java @@ -0,0 +1,10 @@ +package app.fedilab.android.helper; + +import android.text.style.ClickableSpan; +import android.view.View; + +public abstract class LongClickableSpan extends ClickableSpan { + + abstract public void onLongClick(View view); + +} \ No newline at end of file diff --git a/app/src/main/java/app/fedilab/android/helper/SpannableHelper.java b/app/src/main/java/app/fedilab/android/helper/SpannableHelper.java index 3b962137..d70ec631 100644 --- a/app/src/main/java/app/fedilab/android/helper/SpannableHelper.java +++ b/app/src/main/java/app/fedilab/android/helper/SpannableHelper.java @@ -15,15 +15,21 @@ package app.fedilab.android.helper; * see . */ +import static app.fedilab.android.helper.Helper.USER_AGENT; import static app.fedilab.android.helper.Helper.convertDpToPixel; +import static app.fedilab.android.helper.Helper.urlPattern; import static app.fedilab.android.helper.ThemeHelper.linkColor; +import android.content.ClipData; +import android.content.ClipboardManager; import android.content.Context; import android.content.Intent; import android.content.SharedPreferences; import android.graphics.drawable.Drawable; +import android.net.Uri; import android.os.Build; import android.os.Bundle; +import android.os.Handler; import android.text.Html; import android.text.Spannable; import android.text.SpannableString; @@ -34,9 +40,12 @@ import android.text.style.ClickableSpan; import android.text.style.ImageSpan; import android.text.style.URLSpan; import android.util.Patterns; +import android.view.LayoutInflater; import android.view.View; +import android.widget.Toast; import androidx.annotation.NonNull; +import androidx.appcompat.app.AlertDialog; import androidx.preference.PreferenceManager; import com.bumptech.glide.Glide; @@ -47,15 +56,23 @@ import com.github.penfeizhou.animation.gif.GifDrawable; import com.github.penfeizhou.animation.gif.decode.GifParser; import java.io.File; +import java.io.IOException; +import java.net.MalformedURLException; +import java.net.URL; import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.concurrent.ExecutionException; import java.util.regex.Matcher; import java.util.regex.Pattern; +import javax.net.ssl.HttpsURLConnection; + import app.fedilab.android.R; +import app.fedilab.android.activities.ContextActivity; import app.fedilab.android.activities.HashTagActivity; +import app.fedilab.android.activities.MainActivity; import app.fedilab.android.activities.ProfileActivity; import app.fedilab.android.client.mastodon.entities.Account; import app.fedilab.android.client.mastodon.entities.Attachment; @@ -64,6 +81,8 @@ import app.fedilab.android.client.mastodon.entities.Field; import app.fedilab.android.client.mastodon.entities.Mention; import app.fedilab.android.client.mastodon.entities.Poll; import app.fedilab.android.client.mastodon.entities.Status; +import app.fedilab.android.databinding.PopupLinksBinding; +import es.dmoral.toasty.Toasty; public class SpannableHelper { @@ -188,11 +207,172 @@ public class SpannableHelper { } if (matchStart >= 0 && matchEnd <= content.length() && matchEnd >= matchStart) { - content.setSpan(new ClickableSpan() { + content.setSpan(new LongClickableSpan() { + @Override + public void onLongClick(View view) { + AlertDialog.Builder dialogBuilder = new AlertDialog.Builder(view.getContext(), Helper.dialogStyle()); + PopupLinksBinding popupLinksBinding = PopupLinksBinding.inflate(LayoutInflater.from(context)); + dialogBuilder.setView(popupLinksBinding.getRoot()); + AlertDialog alertDialog = dialogBuilder.create(); + alertDialog.show(); + + popupLinksBinding.displayFullLink.setOnClickListener(v -> { + AlertDialog.Builder builder = new AlertDialog.Builder(context, Helper.dialogStyle()); + builder.setMessage(url); + builder.setTitle(context.getString(R.string.display_full_link)); + builder.setPositiveButton(R.string.close, (dialog, which) -> dialog.dismiss()) + .show(); + alertDialog.dismiss(); + }); + popupLinksBinding.shareLink.setOnClickListener(v -> { + Intent sendIntent = new Intent(Intent.ACTION_SEND); + sendIntent.putExtra(Intent.EXTRA_SUBJECT, context.getString(R.string.shared_via)); + sendIntent.putExtra(Intent.EXTRA_TEXT, url); + sendIntent.setType("text/plain"); + context.startActivity(Intent.createChooser(sendIntent, context.getString(R.string.share_with))); + alertDialog.dismiss(); + }); + + popupLinksBinding.openOtherApp.setOnClickListener(v -> { + Intent intent = new Intent(Intent.ACTION_VIEW); + intent.setData(Uri.parse(url)); + try { + context.startActivity(intent); + } catch (Exception e) { + Toasty.error(context, context.getString(R.string.toast_error), Toast.LENGTH_LONG).show(); + } + alertDialog.dismiss(); + }); + + popupLinksBinding.copyLink.setOnClickListener(v -> { + ClipboardManager clipboard = (ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE); + ClipData clip = ClipData.newPlainText(Helper.CLIP_BOARD, url); + if (clipboard != null) { + clipboard.setPrimaryClip(clip); + Toasty.info(context, context.getString(R.string.clipboard_url), Toast.LENGTH_LONG).show(); + } + alertDialog.dismiss(); + }); + + popupLinksBinding.checkRedirect.setOnClickListener(v -> { + try { + + URL finalUrlCheck = new URL(url); + new Thread(() -> { + try { + String redirect = null; + HttpsURLConnection httpsURLConnection = (HttpsURLConnection) finalUrlCheck.openConnection(); + httpsURLConnection.setConnectTimeout(10 * 1000); + httpsURLConnection.setRequestProperty("http.keepAlive", "false"); + httpsURLConnection.setRequestProperty("User-Agent", USER_AGENT); + httpsURLConnection.setRequestMethod("HEAD"); + if (httpsURLConnection.getResponseCode() == 301 || httpsURLConnection.getResponseCode() == 302) { + Map> map = httpsURLConnection.getHeaderFields(); + for (Map.Entry> entry : map.entrySet()) { + if (entry.toString().toLowerCase().startsWith("location")) { + Matcher matcher = urlPattern.matcher(entry.toString()); + if (matcher.find()) { + redirect = matcher.group(1); + } + } + } + } + httpsURLConnection.getInputStream().close(); + if (redirect != null && redirect.compareTo(url) != 0) { + URL redirectURL = new URL(redirect); + String host = redirectURL.getHost(); + String protocol = redirectURL.getProtocol(); + if (protocol == null || host == null) { + redirect = null; + } + } + Handler mainHandler = new Handler(context.getMainLooper()); + String finalRedirect = redirect; + Runnable myRunnable = () -> { + AlertDialog.Builder builder1 = new AlertDialog.Builder(context, Helper.dialogStyle()); + if (finalRedirect != null) { + builder1.setMessage(context.getString(R.string.redirect_detected, url, finalRedirect)); + builder1.setNegativeButton(R.string.copy_link, (dialog, which) -> { + ClipboardManager clipboard1 = (ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE); + ClipData clip1 = ClipData.newPlainText(Helper.CLIP_BOARD, finalRedirect); + if (clipboard1 != null) { + clipboard1.setPrimaryClip(clip1); + Toasty.info(context, context.getString(R.string.clipboard_url), Toast.LENGTH_LONG).show(); + } + dialog.dismiss(); + }); + builder1.setNeutralButton(R.string.share_link, (dialog, which) -> { + Intent sendIntent1 = new Intent(Intent.ACTION_SEND); + sendIntent1.putExtra(Intent.EXTRA_SUBJECT, context.getString(R.string.shared_via)); + sendIntent1.putExtra(Intent.EXTRA_TEXT, url); + sendIntent1.setType("text/plain"); + context.startActivity(Intent.createChooser(sendIntent1, context.getString(R.string.share_with))); + dialog.dismiss(); + }); + } else { + builder1.setMessage(R.string.no_redirect); + } + builder1.setTitle(context.getString(R.string.check_redirect)); + builder1.setPositiveButton(R.string.close, (dialog, which) -> dialog.dismiss()) + .show(); + + }; + mainHandler.post(myRunnable); + } catch (IOException e) { + e.printStackTrace(); + } + + }).start(); + } catch (MalformedURLException e) { + e.printStackTrace(); + } + + alertDialog.dismiss(); + }); + + } + @Override public void onClick(@NonNull View textView) { textView.setTag(CLICKABLE_SPAN); - Helper.openBrowser(context, newURL); + Pattern link = Pattern.compile("https?://([\\da-z.-]+\\.[a-z.]{2,10})/(@[\\w._-]*[0-9]*)(/[0-9]+)?$"); + Matcher matcherLink = link.matcher(url); + if (matcherLink.find() && !url.contains("medium.com")) { + if (matcherLink.group(3) != null && Objects.requireNonNull(matcherLink.group(3)).length() > 0) { //It's a toot + CrossActionHelper.fetchRemoteStatus(context, MainActivity.accountWeakReference.get(), status, new CrossActionHelper.Callback() { + @Override + public void federatedStatus(Status status) { + Intent intent = new Intent(context, ContextActivity.class); + intent.putExtra(Helper.ARG_STATUS, status); + context.startActivity(intent); + } + + @Override + public void federatedAccount(Account account) { + + } + }); + } else {//It's an account + CrossActionHelper.fetchRemoteAccount(context, MainActivity.accountWeakReference.get(), status.account, new CrossActionHelper.Callback() { + @Override + public void federatedStatus(Status status) { + + } + + @Override + public void federatedAccount(Account account) { + Intent intent = new Intent(context, ProfileActivity.class); + Bundle b = new Bundle(); + b.putSerializable(Helper.ARG_ACCOUNT, account); + intent.putExtras(b); + context.startActivity(intent); + } + }); + } + } else { + Helper.openBrowser(context, newURL); + } + } @Override diff --git a/app/src/main/java/app/fedilab/android/ui/drawer/StatusAdapter.java b/app/src/main/java/app/fedilab/android/ui/drawer/StatusAdapter.java index d521aedc..41cb4319 100644 --- a/app/src/main/java/app/fedilab/android/ui/drawer/StatusAdapter.java +++ b/app/src/main/java/app/fedilab/android/ui/drawer/StatusAdapter.java @@ -42,7 +42,6 @@ import android.text.Layout; import android.text.SpannableString; import android.text.Spanned; import android.text.TextUtils; -import android.text.method.LinkMovementMethod; import android.text.style.ForegroundColorSpan; import android.util.TypedValue; import android.view.ContextThemeWrapper; @@ -116,6 +115,7 @@ import app.fedilab.android.databinding.LayoutMediaBinding; import app.fedilab.android.databinding.LayoutPollItemBinding; import app.fedilab.android.helper.CrossActionHelper; import app.fedilab.android.helper.Helper; +import app.fedilab.android.helper.LongClickLinkMovementMethod; import app.fedilab.android.helper.MastodonHelper; import app.fedilab.android.helper.SpannableHelper; import app.fedilab.android.helper.ThemeHelper; @@ -940,8 +940,8 @@ public class StatusAdapter extends RecyclerView.Adapter holder.binding.mediaContainer.setVisibility(View.GONE); holder.binding.attachmentsListContainer.setVisibility(View.GONE); } - holder.binding.statusContent.setMovementMethod(LinkMovementMethod.getInstance()); - + holder.binding.statusContent.setMovementMethod(LongClickLinkMovementMethod.getInstance()); + //holder.binding.statusContent.setMovementMethod(LinkMovementMethod.getInstance()); holder.binding.reblogInfo.setOnClickListener(v -> { if (remote) { Toasty.info(context, context.getString(R.string.retrieve_remote_status), Toasty.LENGTH_SHORT).show(); diff --git a/app/src/main/res/layout/popup_links.xml b/app/src/main/res/layout/popup_links.xml new file mode 100644 index 00000000..e6c5f789 --- /dev/null +++ b/app/src/main/res/layout/popup_links.xml @@ -0,0 +1,69 @@ + + + + + + + + + + + + +