Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Unverified Commit f8201e24 authored by Marten Gajda's avatar Marten Gajda Committed by GitHub
Browse files

Linkify Task description, fixes #929 (#985)

Linkification of task description was temporarily removed when the
checklist implementation was improved. This commit introduces a floating
action mode which prompts the user before opening a link. This also
fixes #895.
parent f81e3f7d
Loading
Loading
Loading
Loading
+1 −1
Original line number Diff line number Diff line
def jems_version = '1.33'
def jems_version = '1.43'
def contentpal_version = '0.6'
def androidx_test_runner_version = '1.1.1'

+3 −0
Original line number Diff line number Diff line
@@ -96,6 +96,9 @@ dependencies {
    androidTestImplementation deps.support_test_runner
    androidTestImplementation deps.support_test_rules
    implementation project(path: ':opentaskspal')

    implementation 'io.reactivex.rxjava2:rxjava:2.2.21'
    implementation 'io.reactivex.rxjava2:rxandroid:2.1.1'
}

if (project.hasProperty('PLAY_STORE_SERVICE_ACCOUNT_CREDENTIALS')) {
+171 −0
Original line number Diff line number Diff line
/*
 * Copyright 2021 dmfs GmbH
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package org.dmfs.tasks.linkify;

import android.annotation.SuppressLint;
import android.graphics.Rect;
import android.net.Uri;
import android.os.Build;
import android.telephony.PhoneNumberUtils;
import android.text.Layout;
import android.text.SpannableString;
import android.text.Spanned;
import android.text.style.ClickableSpan;
import android.view.ActionMode;
import android.view.Menu;
import android.view.MenuItem;
import android.view.View;
import android.widget.TextView;

import org.dmfs.jems.iterable.elementary.Seq;
import org.dmfs.jems.procedure.composite.ForEach;

import java.util.regex.Matcher;
import java.util.regex.Pattern;

import androidx.annotation.NonNull;
import io.reactivex.Completable;

import static io.reactivex.Completable.ambArray;
import static org.dmfs.tasks.linkify.ViewObservables.activityTouchEvents;
import static org.dmfs.tasks.linkify.ViewObservables.textChanges;


/**
 * Adds clickable links that, on click, present a (floating) action mode to the user (unless running on Android 5).
 */
public class ActionModeLinkify
{
    private final static String TEL_PATTERN = "(?:\\+\\d{1,5}\\s*)?(?:\\(\\d{1,6}\\)\\s*)?\\d[-, \\.\\/\\d]{4,}\\d";
    private final static String URL_PATTERN = "(?:https?:\\/\\/(?:www\\.|(?!www))[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\\.[^\\s]{2,}|www\\.[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\\.[^\\s]{2,}|https?:\\/\\/(?:www\\.|(?!www))[a-zA-Z0-9]+\\.[^\\s]{2,}|www\\.[a-zA-Z0-9]+\\.[^\\s]{2,})";
    private final static String MAIL_PATTERN = "(?:[a-zA-Z\\d!#$%&'*+/=?^_`{|}~-]+(?:\\.[a-zA-Z\\d!#$%&'*+/=?^_`{|}~-]+)*|\"(?:[\\x01-\\x08\\x0b\\x0c\\x0e-\\x1f\\x21\\x23-\\x5b\\x5d-\\x7f]|\\\\[\\x01-\\x09\\x0b\\x0c\\x0e-\\x7f])*\")@(?:(?:[a-z\\d](?:[a-z\\d-]*[a-z\\d])?\\.)+[a-z\\d](?:[a-z\\d-]*[a-z\\d])?|\\[(?:(?:(?:2(?:5[0-5]|[0-4]\\d)|1\\d\\d|\\d?\\d))\\.){3}(?:(?:2(?:5[0-5]|[0-4]\\d)|1\\d\\d|\\d?\\d)|[a-z\\d-]*[a-z\\d]:(?:[\\x01-\\x08\\x0b\\x0c\\x0e-\\x1f\\x21-\\x5a\\x53-\\x7f]|\\\\[\\x01-\\x09\\x0b\\x0c\\x0e-\\x7f])+)\\])";
    private final static Pattern LINK_PATTERN = Pattern.compile(String.format("(?:^|\\s+)((%s)|(%s)|(%s))(?:\\s+|$)", TEL_PATTERN, URL_PATTERN, MAIL_PATTERN));


    public interface ActionModeListener
    {
        boolean prepareMenu(TextView view, Uri uri, Menu menu);

        boolean onClick(TextView view, Uri uri, MenuItem item);
    }


    public static void linkify(TextView textView, ActionModeListener listener)
    {
        CharSequence text = textView.getText();
        Matcher m = LINK_PATTERN.matcher(text);
        SpannableString s = new SpannableString(text);
        new ForEach<>(new Seq<>(s.getSpans(0, s.length(), ClickableSpan.class))).process(s::removeSpan);
        while (m.find())
        {
            int start = m.start(1);
            int end = m.end(1);
            Uri uri = null;
            if (m.group(2) != null)
            {
                uri = Uri.parse("tel:" + PhoneNumberUtils.normalizeNumber(m.group(2)));
            }
            if (m.group(3) != null)
            {
                uri = Uri.parse(m.group(3));
                if (uri.getScheme() == null)
                {
                    uri = uri.buildUpon().scheme("https").build();
                }
            }
            if (m.group(4) != null)
            {
                uri = Uri.parse("mailto:" + m.group(4));
            }
            Uri finalUri = uri;
            s.setSpan(new ClickableSpan()
                      {
                          @SuppressLint("CheckResult")
                          @Override
                          public void onClick(@NonNull View widget)
                          {
                              if (Build.VERSION.SDK_INT >= 23)
                              {
                                  Completable closeActionTrigger = ambArray(
                                          textChanges(textView).firstElement().ignoreElement(),
                                          activityTouchEvents(textView).firstElement().ignoreElement())
                                          .cache();

                                  ActionMode.Callback2 actionMode = new ActionMode.Callback2()
                                  {

                                      @Override
                                      public boolean onCreateActionMode(ActionMode mode, Menu menu)
                                      {
                                          return listener.prepareMenu(textView, finalUri, menu);
                                      }


                                      @Override
                                      public boolean onPrepareActionMode(ActionMode mode, Menu menu)
                                      {
                                          return true;
                                      }


                                      @Override
                                      public boolean onActionItemClicked(ActionMode mode, MenuItem item)
                                      {
                                          return listener.onClick(textView, finalUri, item);
                                      }


                                      @Override
                                      public void onDestroyActionMode(ActionMode mode)
                                      {
                                          closeActionTrigger.subscribe().dispose();
                                      }


                                      @Override
                                      public void onGetContentRect(ActionMode mode, View view, Rect outRect)
                                      {
                                          Layout layout = textView.getLayout();
                                          int firstLine = layout.getLineForOffset(start);
                                          int lastLine = layout.getLineForOffset(end);
                                          layout.getLineBounds(firstLine, outRect);
                                          if (firstLine == lastLine)
                                          {
                                              outRect.left = (int) layout.getPrimaryHorizontal(start);
                                              outRect.right = (int) layout.getPrimaryHorizontal(end);
                                          }
                                          else
                                          {
                                              Rect lastLineBounds = new Rect();
                                              layout.getLineBounds(lastLine, lastLineBounds);
                                              outRect.bottom = lastLineBounds.bottom;
                                          }
                                      }
                                  };

                                  ActionMode am = textView.startActionMode(actionMode, android.view.ActionMode.TYPE_FLOATING);
                                  closeActionTrigger.subscribe(am::finish);
                              }
                          }
                      },
                    start,
                    end,
                    Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
        }
        textView.setText(s);
    }
}
+87 −0
Original line number Diff line number Diff line
/*
 * Copyright 2021 dmfs GmbH
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package org.dmfs.tasks.linkify;

import android.annotation.SuppressLint;
import android.text.Editable;
import android.text.TextWatcher;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;

import io.reactivex.Observable;
import io.reactivex.disposables.Disposables;

import static android.view.ViewGroup.LayoutParams.MATCH_PARENT;


public final class ViewObservables
{
    public static Observable<CharSequence> textChanges(TextView view)
    {
        return Observable.create(emitter -> {
            TextWatcher textWatcher = new TextWatcher()
            {
                @Override
                public void beforeTextChanged(CharSequence s, int start, int count, int after)
                {
                    // nothing
                }


                @Override
                public void onTextChanged(CharSequence s, int start, int before, int count)
                {
                    // nothing
                }


                @Override
                public void afterTextChanged(Editable s)
                {
                    emitter.onNext(s);
                }
            };
            emitter.setDisposable(Disposables.fromRunnable(() -> view.removeTextChangedListener(textWatcher)));
            view.addTextChangedListener(textWatcher);
        });
    }


    @SuppressLint("ClickableViewAccessibility")
    public static Observable<MotionEvent> activityTouchEvents(View view)
    {
        return Observable.create(emitter -> {
            // set up a trap to receive touch events outside the ActionMode view.
            View touchTrap = new View(view.getContext());
            touchTrap.setLayoutParams(new ViewGroup.LayoutParams(MATCH_PARENT, MATCH_PARENT));
            ViewGroup root = (ViewGroup) view.getRootView();
            root.addView(touchTrap);

            emitter.setDisposable(Disposables.fromRunnable(() -> {
                touchTrap.setOnTouchListener(null);
                root.removeView(touchTrap);
            }));

            touchTrap.setOnTouchListener((v, event) -> {
                emitter.onNext(event);
                return false;
            });
        });
    }
}
 No newline at end of file
+77 −1
Original line number Diff line number Diff line
@@ -18,13 +18,19 @@ package org.dmfs.tasks.widget;

import android.animation.LayoutTransition;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.content.res.ColorStateList;
import android.graphics.Rect;
import android.net.Uri;
import android.text.Editable;
import android.text.TextWatcher;
import android.text.method.LinkMovementMethod;
import android.util.AttributeSet;
import android.view.KeyEvent;
import android.view.LayoutInflater;
import android.view.Menu;
import android.view.MenuItem;
import android.view.View;
import android.view.View.OnClickListener;
import android.view.ViewGroup;
@@ -40,8 +46,14 @@ import com.jmedeisis.draglinearlayout.DragLinearLayout.OnViewSwapListener;

import org.dmfs.android.bolts.color.colors.AttributeColor;
import org.dmfs.jems.function.Function;
import org.dmfs.jems.iterable.composite.Joined;
import org.dmfs.jems.optional.Optional;
import org.dmfs.jems.optional.decorators.Mapped;
import org.dmfs.jems.optional.elementary.Present;
import org.dmfs.jems.procedure.Procedure;
import org.dmfs.jems.procedure.composite.ForEach;
import org.dmfs.tasks.R;
import org.dmfs.tasks.linkify.ActionModeLinkify;
import org.dmfs.tasks.model.ContentSet;
import org.dmfs.tasks.model.DescriptionItem;
import org.dmfs.tasks.model.FieldDescriptor;
@@ -52,13 +64,15 @@ import java.util.List;

import androidx.core.view.ViewCompat;

import static org.dmfs.jems.optional.elementary.Absent.absent;


/**
 * View widget for descriptions with checklists.
 *
 * @author Marten Gajda <marten@dmfs.org>
 */
public class DescriptionFieldView extends AbstractFieldView implements OnCheckedChangeListener, OnViewSwapListener, OnClickListener
public class DescriptionFieldView extends AbstractFieldView implements OnCheckedChangeListener, OnViewSwapListener, OnClickListener, ActionModeLinkify.ActionModeListener
{
    private DescriptionFieldAdapter mAdapter;
    private DragLinearLayout mContainer;
@@ -228,6 +242,7 @@ public class DescriptionFieldView extends AbstractFieldView implements OnChecked
        transition.disableTransitionType(LayoutTransition.CHANGING);
        transition.disableTransitionType(LayoutTransition.APPEARING);
        transition.disableTransitionType(LayoutTransition.DISAPPEARING);
        ((TextView) item.findViewById(android.R.id.title)).setMovementMethod(LinkMovementMethod.getInstance());
        return item;
    }

@@ -273,6 +288,7 @@ public class DescriptionFieldView extends AbstractFieldView implements OnChecked
            }
            else
            {
                ActionModeLinkify.linkify(text, DescriptionFieldView.this);
                ((ViewGroup) itemView.findViewById(R.id.action_bar)).removeAllViews();
            }
        });
@@ -357,10 +373,70 @@ public class DescriptionFieldView extends AbstractFieldView implements OnChecked
        };

        text.setTag(watcher);
        ActionModeLinkify.linkify(text, this);
        text.addTextChangedListener(watcher);
    }


    @Override
    public boolean prepareMenu(TextView view, Uri uri, Menu menu)
    {
        Optional<String> optAction = actionForUri(uri);
        new ForEach<>(new Joined<>(
                new Mapped<>(action -> getContext().getPackageManager()
                        .queryIntentActivities(new Intent(action).setData(uri), PackageManager.GET_RESOLVED_FILTER | PackageManager.GET_META_DATA),
                        optAction)))
                .process(
                        resolveInfo -> menu.add(titleForAction(optAction.value()))
                                .setIcon(resolveInfo.loadIcon(getContext().getPackageManager()))
                                .setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS)
                );
        return menu.size() > 0;
    }


    @Override
    public boolean onClick(TextView view, Uri uri, MenuItem item)
    {
        new ForEach<>(actionForUri(uri)).process(
                action -> getContext().startActivity(new Intent(action).setData(uri)));
        return false;
    }


    private static Optional<String> actionForUri(Uri uri)
    {
        if ("http".equals(uri.getScheme()) || "https".equals(uri.getScheme()))
        {
            return new Present<>(Intent.ACTION_VIEW);
        }
        else if ("mailto".equals(uri.getScheme()))
        {
            return new Present<>(Intent.ACTION_SENDTO);
        }
        else if ("tel".equals(uri.getScheme()))
        {
            return new Present<>(Intent.ACTION_DIAL);
        }
        return absent();
    }


    private static int titleForAction(String action)
    {
        switch (action)
        {
            case Intent.ACTION_DIAL:
                return R.string.opentasks_actionmode_call;
            case Intent.ACTION_SENDTO:
                return R.string.opentasks_actionmode_mail_to;
            case Intent.ACTION_VIEW:
                return R.string.opentasks_actionmode_open;
        }
        return -1;
    }


    /**
     * Insert an empty item at the given position. Nothing will be inserted if the check list already contains an empty item at the given position. The new (or
     * exiting) emtpy item will be focused and the keyboard will be opened.
Loading