diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/qr/QrView.java b/app/src/main/java/org/thoughtcrime/securesms/components/qr/QrView.java new file mode 100644 index 000000000..d2a6b197a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/qr/QrView.java @@ -0,0 +1,93 @@ +package org.thoughtcrime.securesms.components.qr; + +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.Bitmap; +import android.graphics.Color; +import android.util.AttributeSet; +import android.util.Log; + +import androidx.annotation.ColorInt; +import androidx.annotation.Nullable; +import androidx.annotation.WorkerThread; + +import com.google.zxing.BarcodeFormat; +import com.google.zxing.WriterException; +import com.google.zxing.common.BitMatrix; +import com.google.zxing.qrcode.QRCodeWriter; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.SquareImageView; +import org.thoughtcrime.securesms.qr.QrCode; +import org.thoughtcrime.securesms.util.Stopwatch; +import org.thoughtcrime.securesms.util.concurrent.SerialMonoLifoExecutor; +import org.thoughtcrime.securesms.util.concurrent.SignalExecutors; +import org.thoughtcrime.securesms.util.concurrent.SimpleTask; + +import java.util.concurrent.Executor; + +/** + * Generates a bitmap asynchronously for the supplied {@link BitMatrix} data and displays it. + */ +public class QrView extends SquareImageView { + + private static final @ColorInt int DEFAULT_FOREGROUND_COLOR = Color.BLACK; + private static final @ColorInt int DEFAULT_BACKGROUND_COLOR = Color.TRANSPARENT; + + private @Nullable Bitmap qrBitmap; + private @ColorInt int foregroundColor; + private @ColorInt int backgroundColor; + + public QrView(Context context) { + super(context); + init(null); + } + + public QrView(Context context, AttributeSet attrs) { + super(context, attrs); + init(attrs); + } + + public QrView(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + init(attrs); + } + + private void init(@Nullable AttributeSet attrs) { + if (attrs != null) { + TypedArray typedArray = getContext().getTheme().obtainStyledAttributes(attrs, R.styleable.QrView, 0, 0); + foregroundColor = typedArray.getColor(R.styleable.QrView_qr_foreground_color, DEFAULT_FOREGROUND_COLOR); + backgroundColor = typedArray.getColor(R.styleable.QrView_qr_background_color, DEFAULT_BACKGROUND_COLOR); + typedArray.recycle(); + } else { + foregroundColor = DEFAULT_FOREGROUND_COLOR; + backgroundColor = DEFAULT_BACKGROUND_COLOR; + } + + if (isInEditMode()) { + setQrText("https://signal.org"); + } + } + + public void setQrText(@Nullable String text) { + setQrBitmap(QrCode.create(text, foregroundColor, backgroundColor)); + } + + private void setQrBitmap(@Nullable Bitmap qrBitmap) { + if (this.qrBitmap == qrBitmap) { + return; + } + + if (this.qrBitmap != null) { + this.qrBitmap.recycle(); + } + + this.qrBitmap = qrBitmap; + + setImageBitmap(this.qrBitmap); + } + + public @Nullable Bitmap getQrBitmap() { + return qrBitmap; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/qr/QrCode.java b/app/src/main/java/org/thoughtcrime/securesms/qr/QrCode.java index ea90a9fc8..7e008e4d9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/qr/QrCode.java +++ b/app/src/main/java/org/thoughtcrime/securesms/qr/QrCode.java @@ -5,33 +5,57 @@ import android.graphics.Color; import androidx.annotation.ColorInt; import androidx.annotation.NonNull; -import org.thoughtcrime.securesms.logging.Log; +import androidx.annotation.Nullable; import com.google.zxing.BarcodeFormat; import com.google.zxing.WriterException; import com.google.zxing.common.BitMatrix; import com.google.zxing.qrcode.QRCodeWriter; -public class QrCode { +import org.thoughtcrime.securesms.logging.Log; +import org.thoughtcrime.securesms.util.Stopwatch; - public static final String TAG = QrCode.class.getSimpleName(); +public final class QrCode { - public static @NonNull Bitmap create(String data) { - return create(data, Color.BLACK); + private QrCode() { } - public static @NonNull Bitmap create(String data, @ColorInt int foregroundColor) { - try { - BitMatrix result = new QRCodeWriter().encode(data, BarcodeFormat.QR_CODE, 512, 512); - Bitmap bitmap = Bitmap.createBitmap(result.getWidth(), result.getHeight(), Bitmap.Config.ARGB_8888); + public static final String TAG = Log.tag(QrCode.class); - for (int y = 0; y < result.getHeight(); y++) { - for (int x = 0; x < result.getWidth(); x++) { - if (result.get(x, y)) { - bitmap.setPixel(x, y, foregroundColor); - } + public static @NonNull Bitmap create(@Nullable String data) { + return create(data, Color.BLACK, Color.TRANSPARENT); + } + + public static @NonNull Bitmap create(@Nullable String data, + @ColorInt int foregroundColor, + @ColorInt int backgroundColor) + { + if (data == null || data.length() == 0) { + Log.w(TAG, "No data"); + return Bitmap.createBitmap(512, 512, Bitmap.Config.ARGB_8888); + } + + try { + Stopwatch stopwatch = new Stopwatch("QrGen"); + QRCodeWriter qrCodeWriter = new QRCodeWriter(); + BitMatrix qrData = qrCodeWriter.encode(data, BarcodeFormat.QR_CODE, 512, 512); + int qrWidth = qrData.getWidth(); + int qrHeight = qrData.getHeight(); + int[] pixels = new int[qrWidth * qrHeight]; + + for (int y = 0; y < qrHeight; y++) { + int offset = y * qrWidth; + + for (int x = 0; x < qrWidth; x++) { + pixels[offset + x] = qrData.get(x, y) ? foregroundColor : backgroundColor; } } + stopwatch.split("Write pixels"); + + Bitmap bitmap = Bitmap.createBitmap(pixels, qrWidth, qrHeight, Bitmap.Config.ARGB_8888); + + stopwatch.split("Create bitmap"); + stopwatch.stop(TAG); return bitmap; } catch (WriterException e) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/sharablegrouplink/GroupLinkBottomSheetDialogFragment.java b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/sharablegrouplink/GroupLinkBottomSheetDialogFragment.java index 2053ee36b..7122e39e2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/sharablegrouplink/GroupLinkBottomSheetDialogFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/sharablegrouplink/GroupLinkBottomSheetDialogFragment.java @@ -18,6 +18,7 @@ import com.google.android.material.bottomsheet.BottomSheetDialogFragment; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.groups.GroupId; import org.thoughtcrime.securesms.groups.LiveGroup; +import org.thoughtcrime.securesms.recipients.ui.sharablegrouplink.qr.GroupLinkShareQrDialogFragment; import org.thoughtcrime.securesms.util.BottomSheetUtil; import org.thoughtcrime.securesms.util.ThemeUtil; import org.thoughtcrime.securesms.util.Util; @@ -77,8 +78,10 @@ public final class GroupLinkBottomSheetDialogFragment extends BottomSheetDialogF dismiss(); }); - viewQrButton.setOnClickListener(v -> dismiss()); // Todo [Alan] GV2 Add share QR within signal - viewQrButton.setVisibility(View.GONE); + viewQrButton.setOnClickListener(v -> { + GroupLinkShareQrDialogFragment.show(requireFragmentManager(), groupId); + dismiss(); + }); shareBySystemButton.setOnClickListener(v -> { ShareCompat.IntentBuilder.from(requireActivity()) diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/sharablegrouplink/qr/GroupLinkShareQrDialogFragment.java b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/sharablegrouplink/qr/GroupLinkShareQrDialogFragment.java new file mode 100644 index 000000000..609a31fc1 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/sharablegrouplink/qr/GroupLinkShareQrDialogFragment.java @@ -0,0 +1,92 @@ +package org.thoughtcrime.securesms.recipients.ui.sharablegrouplink.qr; + +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.widget.Toolbar; +import androidx.fragment.app.DialogFragment; +import androidx.fragment.app.FragmentManager; +import androidx.lifecycle.ViewModelProviders; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.qr.QrView; +import org.thoughtcrime.securesms.groups.GroupId; +import org.thoughtcrime.securesms.logging.Log; +import org.thoughtcrime.securesms.util.BottomSheetUtil; +import org.thoughtcrime.securesms.util.ThemeUtil; + +import java.util.Objects; + +public class GroupLinkShareQrDialogFragment extends DialogFragment { + + private static final String TAG = Log.tag(GroupLinkShareQrDialogFragment.class); + + private static final String ARG_GROUP_ID = "group_id"; + + private GroupLinkShareQrViewModel viewModel; + private QrView qrImageView; + private View shareCodeButton; + + public static void show(@NonNull FragmentManager manager, @NonNull GroupId.V2 groupId) { + DialogFragment fragment = new GroupLinkShareQrDialogFragment(); + Bundle args = new Bundle(); + + args.putString(ARG_GROUP_ID, groupId.toString()); + fragment.setArguments(args); + + fragment.show(manager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG); + } + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + setStyle(STYLE_NO_FRAME, ThemeUtil.isDarkTheme(requireActivity()) ? R.style.TextSecure_DarkTheme + : R.style.TextSecure_LightTheme); + } + + @Override + public @Nullable View onCreateView(@NonNull LayoutInflater inflater, + @Nullable ViewGroup container, + @Nullable Bundle savedInstanceState) + { + return inflater.inflate(R.layout.group_link_share_qr_dialog_fragment, container, false); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + initializeViewModel(); + initializeViews(view); + } + + private void initializeViewModel() { + Bundle arguments = requireArguments(); + GroupId.V2 groupId = GroupId.parseOrThrow(Objects.requireNonNull(arguments.getString(ARG_GROUP_ID))).requireV2(); + GroupLinkShareQrViewModel.Factory factory = new GroupLinkShareQrViewModel.Factory(groupId); + + viewModel = ViewModelProviders.of(this, factory).get(GroupLinkShareQrViewModel.class); + } + + private void initializeViews(@NonNull View view) { + Toolbar toolbar = view.findViewById(R.id.group_link_share_qr_toolbar); + + qrImageView = view.findViewById(R.id.group_link_share_qr_image); + shareCodeButton = view.findViewById(R.id.group_link_share_code_button); + + toolbar.setNavigationOnClickListener(v -> dismissAllowingStateLoss()); + + viewModel.getQrUrl().observe(getViewLifecycleOwner(), this::presentUrl); + } + + private void presentUrl(@Nullable String url) { + qrImageView.setQrText(url); + + shareCodeButton.setOnClickListener(v -> { + // TODO [Alan] GV2 Allow qr image share + }); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/sharablegrouplink/qr/GroupLinkShareQrViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/sharablegrouplink/qr/GroupLinkShareQrViewModel.java new file mode 100644 index 000000000..7984f2305 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/sharablegrouplink/qr/GroupLinkShareQrViewModel.java @@ -0,0 +1,41 @@ +package org.thoughtcrime.securesms.recipients.ui.sharablegrouplink.qr; + +import androidx.annotation.NonNull; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.Transformations; +import androidx.lifecycle.ViewModel; +import androidx.lifecycle.ViewModelProvider; + +import org.thoughtcrime.securesms.groups.GroupId; +import org.thoughtcrime.securesms.groups.LiveGroup; +import org.thoughtcrime.securesms.groups.v2.GroupLinkUrlAndStatus; + +public final class GroupLinkShareQrViewModel extends ViewModel { + + private final LiveData qrData; + + private GroupLinkShareQrViewModel(@NonNull GroupId.V2 groupId) { + LiveGroup liveGroup = new LiveGroup(groupId); + + this.qrData = Transformations.map(liveGroup.getGroupLink(), GroupLinkUrlAndStatus::getUrl); + } + + LiveData getQrUrl() { + return qrData; + } + + public static final class Factory implements ViewModelProvider.Factory { + + private final GroupId.V2 groupId; + + public Factory(@NonNull GroupId.V2 groupId) { + this.groupId = groupId; + } + + @Override + public @NonNull T create(@NonNull Class modelClass) { + //noinspection ConstantConditions + return modelClass.cast(new GroupLinkShareQrViewModel(groupId)); + } + } +} diff --git a/app/src/main/res/layout/group_link_share_qr_dialog_fragment.xml b/app/src/main/res/layout/group_link_share_qr_dialog_fragment.xml new file mode 100644 index 000000000..65057817a --- /dev/null +++ b/app/src/main/res/layout/group_link_share_qr_dialog_fragment.xml @@ -0,0 +1,66 @@ + + + + + + + + + +