CREATOR=new AutoCreator<>(ModuleInstallResponse.class);
+
+ @Override
+ public String toString(){
+ return "ModuleInstallResponse{"+
+ "sessionID="+sessionID+
+ ", shouldUnregisterListener="+shouldUnregisterListener+
+ '}';
+ }
+}
diff --git a/mastodon/src/main/java/com/google/android/gms/common/moduleinstall/ModuleInstallStatusUpdate.java b/mastodon/src/main/java/com/google/android/gms/common/moduleinstall/ModuleInstallStatusUpdate.java
new file mode 100644
index 0000000000..c0a52379dd
--- /dev/null
+++ b/mastodon/src/main/java/com/google/android/gms/common/moduleinstall/ModuleInstallStatusUpdate.java
@@ -0,0 +1,63 @@
+package com.google.android.gms.common.moduleinstall;
+
+import org.microg.safeparcel.AutoSafeParcelable;
+import org.microg.safeparcel.SafeParceled;
+
+public class ModuleInstallStatusUpdate extends AutoSafeParcelable{
+ public static final int STATE_UNKNOWN = 0;
+ /**
+ * The request is pending and will be processed soon.
+ */
+ public static final int STATE_PENDING = 1;
+ /**
+ * The optional module download is in progress.
+ */
+ public static final int STATE_DOWNLOADING = 2;
+ /**
+ * The optional module download has been canceled.
+ */
+ public static final int STATE_CANCELED = 3;
+ /**
+ * Installation is completed; the optional modules are available to the client app.
+ */
+ public static final int STATE_COMPLETED = 4;
+ /**
+ * The optional module download or installation has failed.
+ */
+ public static final int STATE_FAILED = 5;
+ /**
+ * The optional modules have been downloaded and the installation is in progress.
+ */
+ public static final int STATE_INSTALLING = 6;
+ /**
+ * The optional module download has been paused.
+ *
+ * This usually happens when connectivity requirements can't be met during download. Once the connectivity requirements
+ * are met, the download will be resumed automatically.
+ */
+ public static final int STATE_DOWNLOAD_PAUSED = 7;
+
+ @SafeParceled(1)
+ public int sessionID;
+ @SafeParceled(2)
+ public int installState;
+ @SafeParceled(3)
+ public Long bytesDownloaded;
+ @SafeParceled(4)
+ public Long totalBytesToDownload;
+ @SafeParceled(5)
+ public int errorCode;
+
+ @Override
+ public String toString(){
+ return "ModuleInstallStatusUpdate{"+
+ "sessionID="+sessionID+
+ ", installState="+installState+
+ ", bytesDownloaded="+bytesDownloaded+
+ ", totalBytesToDownload="+totalBytesToDownload+
+ ", errorCode="+errorCode+
+ '}';
+ }
+
+ public static final Creator CREATOR=new AutoCreator<>(ModuleInstallStatusUpdate.class);
+}
diff --git a/mastodon/src/main/java/com/google/android/gms/common/moduleinstall/internal/ApiFeatureRequest.java b/mastodon/src/main/java/com/google/android/gms/common/moduleinstall/internal/ApiFeatureRequest.java
new file mode 100644
index 0000000000..4ff5e38c1a
--- /dev/null
+++ b/mastodon/src/main/java/com/google/android/gms/common/moduleinstall/internal/ApiFeatureRequest.java
@@ -0,0 +1,21 @@
+package com.google.android.gms.common.moduleinstall.internal;
+
+import com.google.android.gms.common.Feature;
+
+import org.microg.safeparcel.AutoSafeParcelable;
+import org.microg.safeparcel.SafeParceled;
+
+import java.util.List;
+
+public class ApiFeatureRequest extends AutoSafeParcelable{
+ @SafeParceled(value=1, subClass=Feature.class)
+ public List features;
+ @SafeParceled(2)
+ public boolean urgent;
+ @SafeParceled(3)
+ public String sessionId;
+ @SafeParceled(4)
+ public String callingPackage;
+
+ public static final Creator CREATOR=new AutoCreator<>(ApiFeatureRequest.class);
+}
diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/ProfileFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/ProfileFragment.java
index 42d89fcb6f..f1792d9bdb 100644
--- a/mastodon/src/main/java/org/joinmastodon/android/fragments/ProfileFragment.java
+++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/ProfileFragment.java
@@ -28,6 +28,7 @@
import android.text.TextUtils;
import android.transition.ChangeBounds;
import android.transition.Fade;
+import android.transition.Transition;
import android.transition.TransitionManager;
import android.transition.TransitionSet;
import android.view.Gravity;
@@ -35,7 +36,6 @@
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
-import android.view.SubMenu;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewOutlineProvider;
@@ -66,18 +66,15 @@
import org.joinmastodon.android.api.requests.accounts.SetAccountFollowed;
import org.joinmastodon.android.api.requests.accounts.SetPrivateNote;
import org.joinmastodon.android.api.requests.accounts.UpdateAccountCredentials;
-import org.joinmastodon.android.api.requests.instance.GetInstance;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.fragments.account_list.BlockedAccountsListFragment;
import org.joinmastodon.android.fragments.account_list.FollowerListFragment;
import org.joinmastodon.android.fragments.account_list.FollowingListFragment;
import org.joinmastodon.android.fragments.account_list.MutedAccountsListFragment;
import org.joinmastodon.android.fragments.report.ReportReasonChoiceFragment;
-import org.joinmastodon.android.fragments.settings.SettingsServerFragment;
import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.model.AccountField;
import org.joinmastodon.android.model.Attachment;
-import org.joinmastodon.android.model.Instance;
import org.joinmastodon.android.model.Relationship;
import org.joinmastodon.android.ui.BetterItemAnimator;
import org.joinmastodon.android.ui.M3AlertDialogBuilder;
@@ -160,6 +157,7 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList
private View tabsDivider;
private View actionButtonWrap;
private CustomDrawingOrderLinearLayout scrollableContent;
+ private ImageButton qrCodeButton;
private Account account, remoteAccount;
private String accountID;
@@ -275,6 +273,7 @@ public View onCreateContentView(LayoutInflater inflater, ViewGroup container, Bu
scrollableContent=content.findViewById(R.id.scrollable_content);
list=content.findViewById(R.id.metadata);
rolesView=content.findViewById(R.id.roles);
+ qrCodeButton=content.findViewById(R.id.qr_code);
avatarBorder.setOutlineProvider(OutlineProviders.roundedRect(26));
avatarBorder.setClipToOutline(true);
@@ -435,15 +434,14 @@ public void getOutline(View view, Outline outline){
nameEdit.addTextChangedListener(new SimpleTextWatcher(e->editDirty=true));
bioEdit.addTextChangedListener(new SimpleTextWatcher(e->editDirty=true));
-
-// qrCodeButton.setOnClickListener(v->{
-// Bundle args=new Bundle();
-// args.putString("account", accountID);
-// args.putParcelable("targetAccount", Parcels.wrap(account));
-// ProfileQrCodeFragment qf=new ProfileQrCodeFragment();
-// qf.setArguments(args);
-// qf.show(getChildFragmentManager(), "qrDialog");
-// });
+ qrCodeButton.setOnClickListener(v->{
+ Bundle args=new Bundle();
+ args.putString("account", accountID);
+ args.putParcelable("targetAccount", Parcels.wrap(account));
+ ProfileQrCodeFragment qf=new ProfileQrCodeFragment();
+ qf.setArguments(args);
+ qf.show(getChildFragmentManager(), "qrDialog");
+ });
return sizeWrapper;
}
@@ -1201,18 +1199,53 @@ private void enterEditMode(Account account){
toolbar.setNavigationContentDescription(R.string.discard);
ViewGroup parent=contentView.findViewById(R.id.scrollable_content);
+ Runnable updater=new Runnable(){
+ @Override
+ public void run(){
+ // setPadding() calls nullLayouts() internally, forcing the text layout to update
+ actionButton.setPadding(actionButton.getPaddingLeft(), 1, actionButton.getPaddingRight(), 0);
+ actionButton.setPadding(actionButton.getPaddingLeft(), 0, actionButton.getPaddingRight(), 0);
+ actionButton.measure(actionButton.getWidth()|View.MeasureSpec.EXACTLY, actionButton.getHeight()|View.MeasureSpec.EXACTLY);
+ actionButton.postOnAnimation(this);
+ }
+ };
+ actionButton.postOnAnimation(updater);
TransitionManager.beginDelayedTransition(parent, new TransitionSet()
.addTransition(new Fade(Fade.IN | Fade.OUT))
.addTransition(new ChangeBounds())
.setDuration(250)
.setInterpolator(CubicBezierInterpolator.DEFAULT)
+ .addListener(new Transition.TransitionListener(){
+ @Override
+ public void onTransitionStart(Transition transition){}
+
+ @Override
+ public void onTransitionEnd(Transition transition){
+ actionButton.removeCallbacks(updater);
+ }
+
+ @Override
+ public void onTransitionCancel(Transition transition){}
+
+ @Override
+ public void onTransitionPause(Transition transition){}
+
+ @Override
+ public void onTransitionResume(Transition transition){}
+ })
);
name.setVisibility(View.GONE);
rolesView.setVisibility(View.GONE);
usernameWrap.setVisibility(View.GONE);
+ name.setVisibility(View.GONE);
+ username.setVisibility(View.GONE);
+ name.setVisibility(View.INVISIBLE);
+ username.setVisibility(View.INVISIBLE);
bio.setVisibility(View.GONE);
countersLayout.setVisibility(View.GONE);
+ qrCodeButton.setVisibility(View.GONE);
+ usernameDomain.setVisibility(View.INVISIBLE);
nameEditWrap.setVisibility(View.VISIBLE);
nameEdit.setText(account.displayName);
@@ -1249,11 +1282,40 @@ private void exitEditMode(){
editSaveMenuItem=null;
ViewGroup parent=contentView.findViewById(R.id.scrollable_content);
+ Runnable updater=new Runnable(){
+ @Override
+ public void run(){
+ // setPadding() calls nullLayouts() internally, forcing the text layout to update
+ actionButton.setPadding(actionButton.getPaddingLeft(), 1, actionButton.getPaddingRight(), 0);
+ actionButton.setPadding(actionButton.getPaddingLeft(), 0, actionButton.getPaddingRight(), 0);
+ actionButton.measure(actionButton.getWidth()|View.MeasureSpec.EXACTLY, actionButton.getHeight()|View.MeasureSpec.EXACTLY);
+ actionButton.postOnAnimation(this);
+ }
+ };
+ actionButton.postOnAnimation(updater);
TransitionManager.beginDelayedTransition(parent, new TransitionSet()
.addTransition(new Fade(Fade.IN | Fade.OUT))
.addTransition(new ChangeBounds())
.setDuration(250)
.setInterpolator(CubicBezierInterpolator.DEFAULT)
+ .addListener(new Transition.TransitionListener(){
+ @Override
+ public void onTransitionStart(Transition transition){}
+
+ @Override
+ public void onTransitionEnd(Transition transition){
+ actionButton.removeCallbacks(updater);
+ }
+
+ @Override
+ public void onTransitionCancel(Transition transition){}
+
+ @Override
+ public void onTransitionPause(Transition transition){}
+
+ @Override
+ public void onTransitionResume(Transition transition){}
+ })
);
nameEditWrap.setVisibility(View.GONE);
bioEditWrap.setVisibility(View.GONE);
@@ -1266,6 +1328,8 @@ private void exitEditMode(){
pager.setVisibility(View.VISIBLE);
tabbar.setVisibility(View.VISIBLE);
updateMetadataHeight();
+ usernameDomain.setVisibility(View.VISIBLE);
+ qrCodeButton.setVisibility(View.VISIBLE);
InputMethodManager imm=getActivity().getSystemService(InputMethodManager.class);
imm.hideSoftInputFromWindow(content.getWindowToken(), 0);
diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/ProfileQrCodeFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/ProfileQrCodeFragment.java
new file mode 100644
index 0000000000..a27fe3eeab
--- /dev/null
+++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/ProfileQrCodeFragment.java
@@ -0,0 +1,597 @@
+package org.joinmastodon.android.fragments;
+
+import android.Manifest;
+import android.animation.Animator;
+import android.animation.AnimatorListenerAdapter;
+import android.animation.AnimatorSet;
+import android.animation.ObjectAnimator;
+import android.annotation.SuppressLint;
+import android.app.Activity;
+import android.app.Dialog;
+import android.app.DownloadManager;
+import android.content.ContentResolver;
+import android.content.ContentValues;
+import android.content.Context;
+import android.content.DialogInterface;
+import android.content.Intent;
+import android.content.pm.ActivityInfo;
+import android.content.pm.PackageManager;
+import android.content.res.Configuration;
+import android.graphics.Bitmap;
+import android.graphics.Canvas;
+import android.graphics.ColorFilter;
+import android.graphics.DashPathEffect;
+import android.graphics.Paint;
+import android.graphics.PixelFormat;
+import android.graphics.Rect;
+import android.graphics.RectF;
+import android.graphics.drawable.Drawable;
+import android.graphics.drawable.GradientDrawable;
+import android.media.MediaScannerConnection;
+import android.net.Uri;
+import android.os.Build;
+import android.os.Bundle;
+import android.os.Environment;
+import android.os.SystemClock;
+import android.provider.MediaStore;
+import android.provider.Settings;
+import android.text.TextUtils;
+import android.util.AttributeSet;
+import android.view.ContextThemeWrapper;
+import android.view.KeyEvent;
+import android.view.LayoutInflater;
+import android.view.Menu;
+import android.view.MenuInflater;
+import android.view.MenuItem;
+import android.view.MotionEvent;
+import android.view.VelocityTracker;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.ViewTreeObserver;
+import android.view.WindowManager;
+import android.widget.ImageView;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+
+import com.google.zxing.BarcodeFormat;
+import com.google.zxing.EncodeHintType;
+import com.google.zxing.WriterException;
+import com.google.zxing.common.BitMatrix;
+import com.google.zxing.qrcode.QRCodeWriter;
+import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel;
+
+import org.joinmastodon.android.MainActivity;
+import org.joinmastodon.android.R;
+import org.joinmastodon.android.api.MastodonAPIController;
+import org.joinmastodon.android.api.session.AccountSessionManager;
+import org.joinmastodon.android.googleservices.GmsClient;
+import org.joinmastodon.android.googleservices.barcodescanner.Barcode;
+import org.joinmastodon.android.googleservices.barcodescanner.BarcodeScanner;
+import org.joinmastodon.android.model.Account;
+import org.joinmastodon.android.ui.M3AlertDialogBuilder;
+import org.joinmastodon.android.ui.Snackbar;
+import org.joinmastodon.android.ui.drawables.FancyQrCodeDrawable;
+import org.joinmastodon.android.ui.drawables.RadialParticleSystemDrawable;
+import org.joinmastodon.android.ui.utils.UiUtils;
+import org.joinmastodon.android.ui.views.FixedAspectRatioFrameLayout;
+import org.parceler.Parcels;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.util.List;
+import java.util.Map;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.dynamicanimation.animation.DynamicAnimation;
+import androidx.dynamicanimation.animation.SpringAnimation;
+import androidx.dynamicanimation.animation.SpringForce;
+import me.grishka.appkit.fragments.AppKitFragment;
+import me.grishka.appkit.imageloader.ViewImageLoader;
+import me.grishka.appkit.imageloader.requests.UrlImageLoaderRequest;
+import me.grishka.appkit.utils.CubicBezierInterpolator;
+import me.grishka.appkit.utils.CustomViewHelper;
+import me.grishka.appkit.utils.V;
+
+public class ProfileQrCodeFragment extends AppKitFragment{
+ private static final String TAG="ProfileQrCodeFragment";
+ private static final int PERMISSION_RESULT=388;
+ private static final int SCAN_RESULT=439;
+
+ private Context themeWrapper;
+ private GradientDrawable scrim=new GradientDrawable(GradientDrawable.Orientation.TOP_BOTTOM, new int[]{0xE6000000, 0xD9000000});
+ private RadialParticleSystemDrawable particles;
+ private View codeContainer;
+ private View particleAnimContainer;
+ private Animator currentTransition;
+ private View saveBtn;
+ private TextView saveBtnText;
+
+ private String accountID;
+ private Account account;
+ private String accountDomain;
+ private Intent scannerIntent;
+
+ @Override
+ public void onCreate(Bundle savedInstanceState){
+ super.onCreate(savedInstanceState);
+ setStyle(STYLE_NO_FRAME, 0);
+ setHasOptionsMenu(true);
+ accountID=getArguments().getString("account");
+ account=Parcels.unwrap(getArguments().getParcelable("targetAccount"));
+ setCancelable(false);
+ scannerIntent=BarcodeScanner.createIntent(Barcode.FORMAT_QR_CODE, false, true);
+ }
+
+ @Override
+ public void onStart(){
+ super.onStart();
+ Dialog dlg=getDialog();
+ dlg.getWindow().setLayout(WindowManager.LayoutParams.MATCH_PARENT, WindowManager.LayoutParams.MATCH_PARENT);
+ dlg.getWindow().addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS | WindowManager.LayoutParams.FLAG_LAYOUT_INSET_DECOR);
+ dlg.getWindow().setNavigationBarColor(0);
+ dlg.getWindow().setStatusBarColor(0);
+ WindowManager.LayoutParams lp=dlg.getWindow().getAttributes();
+ if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.P){
+ lp.layoutInDisplayCutoutMode=WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES;
+ }
+ dlg.getWindow().setAttributes(lp);
+ if(!isTablet){
+ getActivity().setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT);
+ }
+ dlg.setOnKeyListener((dialog, keyCode, event)->{
+ if(keyCode==KeyEvent.KEYCODE_BACK && event.getAction()==KeyEvent.ACTION_DOWN){
+ dismiss();
+ }
+ return true;
+ });
+ }
+
+ @Override
+ public void onDismiss(DialogInterface dialog){
+ super.onDismiss(dialog);
+ Activity activity=getActivity();
+ if(activity!=null)
+ activity.setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED);
+ }
+
+ @Override
+ public void onAttach(Activity activity){
+ super.onAttach(activity);
+ themeWrapper=new ContextThemeWrapper(activity, R.style.Theme_Mastodon_Dark);
+ }
+
+ @SuppressLint("ClickableViewAccessibility")
+ @Nullable
+ @Override
+ public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, Bundle savedInstanceState){
+ View content=View.inflate(themeWrapper, R.layout.fragment_profile_qr, container);
+ View decor=getDialog().getWindow().getDecorView();
+ decor.setOnApplyWindowInsetsListener((v, insets)->{
+ content.setPadding(insets.getStableInsetLeft(), insets.getStableInsetTop(), insets.getStableInsetRight(), insets.getStableInsetBottom());
+ return insets.consumeStableInsets();
+ });
+ int flags=decor.getSystemUiVisibility() | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION | View.SYSTEM_UI_FLAG_LAYOUT_STABLE;
+ flags&=~(View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR | View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR);
+ decor.setSystemUiVisibility(flags);
+ content.setBackground(scrim);
+
+ String url=account.url;
+ QRCodeWriter writer=new QRCodeWriter();
+ BitMatrix code;
+ try{
+ code=writer.encode(url, BarcodeFormat.QR_CODE, 0, 0, Map.of(EncodeHintType.MARGIN, 0, EncodeHintType.ERROR_CORRECTION, ErrorCorrectionLevel.H));
+ }catch(WriterException e){
+ throw new RuntimeException(e);
+ }
+
+ View codeView=content.findViewById(R.id.code);
+ ImageView avatar=content.findViewById(R.id.avatar);
+ TextView username=content.findViewById(R.id.username);
+ TextView domain=content.findViewById(R.id.domain);
+ View share=content.findViewById(R.id.share_btn);
+ saveBtn=content.findViewById(R.id.save_btn);
+ saveBtnText=content.findViewById(R.id.save_text);
+ View cornerAnimContainer=content.findViewById(R.id.corner_animation_container);
+ particleAnimContainer=content.findViewById(R.id.particle_animation_container);
+ codeContainer=content.findViewById(R.id.code_container);
+
+ if(!TextUtils.isEmpty(account.avatar)){
+ ViewImageLoader.loadWithoutAnimation(avatar, getResources().getDrawable(R.drawable.image_placeholder, getActivity().getTheme()), new UrlImageLoaderRequest(Bitmap.Config.ARGB_8888, V.dp(24), V.dp(24), List.of(), Uri.parse(account.avatarStatic)));
+ }
+ username.setText(account.username);
+ String accDomain=account.getDomain();
+ domain.setText(accountDomain=TextUtils.isEmpty(accDomain) ? AccountSessionManager.get(accountID).domain : accDomain);
+ //TODO: replace the app logo with the instance avatar (https://github.com/mastodon/mastodon/pull/30205)
+ Drawable logo=getResources().getDrawable(R.drawable.ic_ntf_logo, themeWrapper.getTheme()).mutate();
+ logo.setTint(UiUtils.getThemeColor(themeWrapper, R.attr.colorM3OnPrimary));
+ codeView.setBackground(new FancyQrCodeDrawable(code, UiUtils.getThemeColor(themeWrapper, R.attr.colorM3OnPrimary), logo));
+
+ share.setOnClickListener(v->{
+ Intent intent=new Intent(Intent.ACTION_SEND);
+ intent.setType("text/plain");
+ intent.putExtra(Intent.EXTRA_TEXT, account.url);
+ startActivity(Intent.createChooser(intent, getString(R.string.share_user)));
+ });
+ saveBtn.setOnClickListener(v->saveCodeAsFile());
+
+ cornerAnimContainer.setBackground(new AnimatedCornersDrawable(themeWrapper));
+ int particleColor=UiUtils.getThemeColor(themeWrapper, R.attr.colorM3Primary);
+ particles=new RadialParticleSystemDrawable(5000, 200, (particleColor & 0xFFFFFF) | 0x80000000, particleColor & 0xFFFFFF, V.dp(65), V.dp(50), getResources().getDisplayMetrics().density);
+ particleAnimContainer.setBackground(particles);
+ content.setOnTouchListener(new TouchDismissListener());
+
+ int buttonExtraWidth=saveBtn.getPaddingLeft()+saveBtn.getPaddingRight()+saveBtnText.getCompoundDrawablesRelative()[0].getIntrinsicWidth()+saveBtnText.getCompoundDrawablePadding();
+ saveBtn.getLayoutParams().width=(int)Math.max(saveBtnText.getPaint().measureText(getString(R.string.save)), saveBtnText.getPaint().measureText(getString(R.string.saved)))+buttonExtraWidth;
+
+ return content;
+ }
+
+ @Override
+ public void onViewCreated(View view, Bundle savedInstanceState){
+ super.onViewCreated(view, savedInstanceState);
+ if(savedInstanceState==null){
+ AnimatorSet set=new AnimatorSet();
+ set.playTogether(
+ ObjectAnimator.ofInt(scrim, "alpha", 0, 255),
+ ObjectAnimator.ofFloat(particleAnimContainer, View.TRANSLATION_Y, V.dp(50), 0),
+ ObjectAnimator.ofFloat(particleAnimContainer, View.ALPHA, 0, 1),
+ ObjectAnimator.ofFloat(getToolbar(), View.ALPHA, 0, 1)
+ );
+ set.setInterpolator(CubicBezierInterpolator.DEFAULT);
+ set.setDuration(350);
+ set.addListener(new AnimatorListenerAdapter(){
+ @Override
+ public void onAnimationEnd(Animator animation){
+ currentTransition=null;
+ }
+ });
+ currentTransition=set;
+ set.start();
+ }
+ }
+
+ @Override
+ public void dismiss(){
+ dismissWithAnimation(super::dismiss, true);
+ }
+
+ @Override
+ public void onCreateOptionsMenu(Menu menu, MenuInflater inflater){
+ if(GmsClient.isGooglePlayServicesAvailable(getActivity())){
+ MenuItem item=menu.add(0, 0, 0, R.string.scan_qr_code);
+ item.setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS);
+ item.setIcon(R.drawable.ic_fluent_barcode_scanner_24_filled);
+ }
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(MenuItem item){
+ if(scannerIntent.resolveActivity(getActivity().getPackageManager())!=null){
+ startActivityForResult(scannerIntent, SCAN_RESULT);
+ }else{
+ BarcodeScanner.installScannerModule(themeWrapper, ()->startActivityForResult(scannerIntent, SCAN_RESULT));
+ }
+ return true;
+ }
+
+ @Override
+ protected boolean canGoBack(){
+ return true;
+ }
+
+ @Override
+ public void onToolbarNavigationClick(){
+ dismiss();
+ }
+
+ @Override
+ public boolean wantsCustomNavigationIcon(){
+ return true;
+ }
+
+ @Override
+ protected int getNavigationIconDrawableResource(){
+ return R.drawable.ic_fluent_dismiss_24_filled;
+ }
+
+ @Override
+ protected LayoutInflater getToolbarLayoutInflater(){
+ return LayoutInflater.from(themeWrapper);
+ }
+
+ @Override
+ protected int getToolbarResource(){
+ return R.layout.profile_qr_toolbar;
+ }
+
+ @Override
+ public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults){
+ if(requestCode==PERMISSION_RESULT){
+ if(grantResults[0]==PackageManager.PERMISSION_GRANTED){
+ doSaveCodeAsFile();
+ }else if(!getActivity().shouldShowRequestPermissionRationale(Manifest.permission.WRITE_EXTERNAL_STORAGE)){
+ new M3AlertDialogBuilder(getActivity())
+ .setTitle(R.string.permission_required)
+ .setMessage(R.string.storage_permission_to_download)
+ .setPositiveButton(R.string.open_settings, (dialog, which)->getActivity().startActivity(new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.fromParts("package", getActivity().getPackageName(), null))))
+ .setNegativeButton(R.string.cancel, null)
+ .show();
+ }
+ }
+ }
+
+ @Override
+ public void onActivityResult(int requestCode, int resultCode, Intent data){
+ if(requestCode==SCAN_RESULT && resultCode==Activity.RESULT_OK && BarcodeScanner.isValidResult(data)){
+ Barcode code=BarcodeScanner.getResult(data);
+ if(code!=null){
+ if(code.rawValue.startsWith("https:")){
+ ((MainActivity)getActivity()).handleURL(Uri.parse(code.rawValue), accountID);
+ dismiss();
+ }
+ }
+ }
+ }
+
+ private void dismissWithAnimation(Runnable onDone, boolean animateTranslationDown){
+ if(currentTransition!=null)
+ currentTransition.cancel();
+ AnimatorSet set=new AnimatorSet();
+ set.playTogether(
+ ObjectAnimator.ofInt(scrim, "alpha", 0),
+ ObjectAnimator.ofFloat(particleAnimContainer, View.TRANSLATION_Y, particleAnimContainer.getTranslationY()+V.dp(animateTranslationDown ? 50 : -50)),
+ ObjectAnimator.ofFloat(particleAnimContainer, View.ALPHA, 0),
+ ObjectAnimator.ofFloat(getToolbar(), View.ALPHA, 0)
+ );
+ set.setInterpolator(CubicBezierInterpolator.DEFAULT);
+ set.setDuration(200);
+ set.addListener(new AnimatorListenerAdapter(){
+ @Override
+ public void onAnimationEnd(Animator animation){
+ onDone.run();
+ }
+ });
+ currentTransition=set;
+ set.start();
+ }
+
+ private void saveCodeAsFile(){
+ if(Build.VERSION.SDK_INT>=29){
+ doSaveCodeAsFile();
+ }else{
+ if(getActivity().checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE)!=PackageManager.PERMISSION_GRANTED){
+ requestPermissions(new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE}, PERMISSION_RESULT);
+ }else{
+ doSaveCodeAsFile();
+ }
+ }
+ }
+
+ private void doSaveCodeAsFile(){
+ Bitmap bmp=Bitmap.createBitmap(1080, 1080, Bitmap.Config.ARGB_8888);
+ Canvas c=new Canvas(bmp);
+ float factor=1080f/codeContainer.getWidth();
+ c.scale(factor, factor);
+ codeContainer.draw(c);
+ Activity activity=getActivity();
+ MastodonAPIController.runInBackground(()->{
+ String fileName=account.username+"_"+accountDomain+".png";
+ try(OutputStream os=destinationStreamForFile(fileName)){
+ bmp.compress(Bitmap.CompressFormat.PNG, 100, os);
+ activity.runOnUiThread(()->{
+ saveBtn.setEnabled(false);
+ saveBtnText.setText(R.string.saved);
+ saveBtnText.setCompoundDrawablesRelativeWithIntrinsicBounds(R.drawable.ic_fluent_checkmark_20_filled, 0, 0, 0);
+ new Snackbar.Builder(activity)
+ .setText(R.string.image_saved)
+ .setAction(R.string.view_file, ()->startActivity(new Intent(DownloadManager.ACTION_VIEW_DOWNLOADS)))
+ .show();
+ });
+ if(Build.VERSION.SDK_INT<29){
+ File dstFile=new File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), fileName);
+ MediaScannerConnection.scanFile(activity, new String[]{dstFile.getAbsolutePath()}, new String[]{"image/png"}, null);
+ }
+ }catch(IOException x){
+ activity.runOnUiThread(()->{
+ new Snackbar.Builder(activity)
+ .setText(R.string.error_saving_file)
+ .show();
+ });
+ }
+ });
+ }
+
+ private OutputStream destinationStreamForFile(String fileName) throws IOException{
+ if(Build.VERSION.SDK_INT>=29){
+ ContentValues values=new ContentValues();
+ values.put(MediaStore.MediaColumns.DISPLAY_NAME, fileName);
+ values.put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS);
+ values.put(MediaStore.MediaColumns.MIME_TYPE, "image/png");
+ ContentResolver cr=getActivity().getContentResolver();
+ Uri itemUri=cr.insert(MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY), values);
+ return cr.openOutputStream(itemUri);
+ }else{
+ return new FileOutputStream(new File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), fileName));
+ }
+ }
+
+ @Override
+ public void onConfigurationChanged(Configuration newConfig){
+ super.onConfigurationChanged(newConfig);
+ codeContainer.getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener(){
+ @Override
+ public boolean onPreDraw(){
+ codeContainer.getViewTreeObserver().removeOnPreDrawListener(this);
+ updateParticleEmitter();
+ return true;
+ }
+ });
+ }
+
+ private void updateParticleEmitter(){
+ int[] loc={0, 0};
+ particleAnimContainer.getLocationInWindow(loc);
+ int x=loc[0], y=loc[1];
+ codeContainer.getLocationInWindow(loc);
+ int cx=loc[0]-x+codeContainer.getWidth()/2;
+ int cy=loc[1]-y+codeContainer.getHeight()/2;
+ int r=codeContainer.getWidth()/2-V.dp(10);
+ particles.setEmitterPosition(cx, cy);
+ particles.setClipOutBounds(cx-r, cy-r, cx+r, cy+r);
+ }
+
+ public static class CustomizedLinearLayout extends LinearLayout implements CustomViewHelper{
+ public CustomizedLinearLayout(Context context){
+ this(context, null);
+ }
+
+ public CustomizedLinearLayout(Context context, AttributeSet attrs){
+ this(context, attrs, 0);
+ }
+
+ public CustomizedLinearLayout(Context context, AttributeSet attrs, int defStyle){
+ super(context, attrs, defStyle);
+ }
+
+ @Override
+ protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec){
+ int maxW=dp(400);
+ FixedAspectRatioFrameLayout aspectLayout=(FixedAspectRatioFrameLayout) getChildAt(0);
+ if(MeasureSpec.getSize(widthMeasureSpec)>maxW){
+ widthMeasureSpec=MeasureSpec.getMode(widthMeasureSpec) | maxW;
+ aspectLayout.setUseHeight(MeasureSpec.getSize(heightMeasureSpec)=V.dp(1000) || Math.abs(particleAnimContainer.getTranslationY())>particleAnimContainer.getHeight()/4f){
+ dismissWithAnimation(ProfileQrCodeFragment.super::dismiss, velocity>0);
+ }else{
+ springBack(velocity);
+ }
+ velocityTracker.recycle();
+ velocityTracker=null;
+ }else if(ev.getAction()==MotionEvent.ACTION_CANCEL){
+ dragging=false;
+ springBack(velocityTracker.getYVelocity());
+ velocityTracker.recycle();
+ velocityTracker=null;
+ }
+ }
+ return true;
+ }
+
+ private void springBack(float velocityY){
+ SpringAnimation anim=new SpringAnimation(particleAnimContainer, DynamicAnimation.TRANSLATION_Y, 0);
+ anim.getSpring().setStiffness(SpringForce.STIFFNESS_LOW).setDampingRatio(SpringForce.DAMPING_RATIO_NO_BOUNCY);
+ anim.setStartVelocity(velocityY);
+ anim.addEndListener((animation, canceled, value, velocity)->springBackAnim=null);
+ anim.addUpdateListener((animation, value, velocity)->{
+ float alpha=1f-Math.abs(particleAnimContainer.getTranslationY())/particleAnimContainer.getHeight();
+ scrim.setAlpha(Math.round(alpha*255));
+ getToolbar().setAlpha(alpha);
+ });
+ springBackAnim=anim;
+ anim.start();
+ }
+ }
+}
diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/ThreadFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/ThreadFragment.java
index 4fa6ba02d4..e0de1e8918 100644
--- a/mastodon/src/main/java/org/joinmastodon/android/fragments/ThreadFragment.java
+++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/ThreadFragment.java
@@ -433,7 +433,7 @@ public void onViewCreated(View view, Bundle savedInstanceState){
replyButton.setOnLongClickListener(this::onReplyLongClick);
Account self=AccountSessionManager.get(accountID).self;
if(!TextUtils.isEmpty(self.avatar)){
- ViewImageLoader.loadWithoutAnimation(replyButtonAva, getResources().getDrawable(R.drawable.image_placeholder), new UrlImageLoaderRequest(self.avatar, V.dp(24), V.dp(24)));
+ ViewImageLoader.loadWithoutAnimation(replyButtonAva, getResources().getDrawable(R.drawable.image_placeholder, getActivity().getTheme()), new UrlImageLoaderRequest(self.avatar, V.dp(24), V.dp(24)));
}
UiUtils.loadCustomEmojiInTextView(toolbarTitleView);
showContent();
diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/DiscoverFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/DiscoverFragment.java
index 69872bd7a3..354d56337f 100644
--- a/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/DiscoverFragment.java
+++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/DiscoverFragment.java
@@ -1,7 +1,10 @@
package org.joinmastodon.android.fragments.discover;
+import android.app.Activity;
import android.app.Fragment;
import android.app.assist.AssistContent;
+import android.content.Intent;
+import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.text.TextUtils;
@@ -12,13 +15,18 @@
import android.widget.ImageButton;
import android.widget.LinearLayout;
import android.widget.TextView;
+import android.widget.Toast;
import org.joinmastodon.android.GlobalUserPreferences;
+import org.joinmastodon.android.MainActivity;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.fragments.IsOnTop;
import org.joinmastodon.android.fragments.ScrollableToTop;
import org.joinmastodon.android.model.Instance;
+import org.joinmastodon.android.googleservices.GmsClient;
+import org.joinmastodon.android.googleservices.barcodescanner.Barcode;
+import org.joinmastodon.android.googleservices.barcodescanner.BarcodeScanner;
import org.joinmastodon.android.model.SearchResult;
import org.joinmastodon.android.ui.OutlineProviders;
import org.joinmastodon.android.ui.SimpleViewHolder;
@@ -39,6 +47,7 @@
public class DiscoverFragment extends AppKitFragment implements ScrollableToTop, OnBackPressedListener, IsOnTop, ProvidesAssistContent{
private static final int QUERY_RESULT=937;
+ private static final int SCAN_RESULT=456;
private TabLayout tabLayout;
private ViewPager2 pager;
@@ -46,7 +55,7 @@ public class DiscoverFragment extends AppKitFragment implements ScrollableToTop,
private TabLayoutMediator tabLayoutMediator;
private boolean searchActive;
private FrameLayout searchView;
- private ImageButton searchBack;
+ private ImageButton searchBack, searchScanQR;
private TextView searchText;
private View tabsDivider;
@@ -58,6 +67,7 @@ public class DiscoverFragment extends AppKitFragment implements ScrollableToTop,
private String accountID;
private String currentQuery;
+ private Intent scannerIntent;
private boolean disableDiscover;
@@ -68,6 +78,7 @@ public void onCreate(Bundle savedInstanceState){
setRetainInstance(true);
accountID=getArguments().getString("account");
+ scannerIntent=BarcodeScanner.createIntent(Barcode.FORMAT_QR_CODE, false, true);
}
@Nullable
@@ -183,6 +194,12 @@ public void onTabReselected(TabLayout.Tab tab){
tabLayout.setVisibility(View.GONE);
searchView.setVisibility(View.VISIBLE);
}
+ searchScanQR=view.findViewById(R.id.search_scan_qr);
+ if(!GmsClient.isGooglePlayServicesAvailable(getActivity())){
+ searchScanQR.setVisibility(View.GONE);
+ }else{
+ searchScanQR.setOnClickListener(v->openQrScanner());
+ }
View searchWrap=view.findViewById(R.id.search_wrap);
searchWrap.setOutlineProvider(OutlineProviders.roundedRect(28));
@@ -300,6 +317,28 @@ public void onProvideAssistContent(AssistContent assistContent) {
: getFragmentForPage(pager.getCurrentItem()), assistContent);
}
+ @Override
+ public void onActivityResult(int requestCode, int resultCode, Intent data){
+ if(requestCode==SCAN_RESULT && resultCode==Activity.RESULT_OK && BarcodeScanner.isValidResult(data)){
+ Barcode code=BarcodeScanner.getResult(data);
+ if(code!=null){
+ if(code.rawValue.startsWith("https:") || code.rawValue.startsWith("http:")){
+ ((MainActivity)getActivity()).handleURL(Uri.parse(code.rawValue), accountID);
+ }else{
+ Toast.makeText(getActivity(), R.string.link_not_supported, Toast.LENGTH_SHORT).show();
+ }
+ }
+ }
+ }
+
+ private void openQrScanner(){
+ if(scannerIntent.resolveActivity(getActivity().getPackageManager())!=null){
+ startActivityForResult(scannerIntent, SCAN_RESULT);
+ }else{
+ BarcodeScanner.installScannerModule(getActivity(), ()->startActivityForResult(scannerIntent, SCAN_RESULT));
+ }
+ }
+
private class DiscoverPagerAdapter extends RecyclerView.Adapter{
@NonNull
@Override
diff --git a/mastodon/src/main/java/org/joinmastodon/android/googleservices/ConnectionResult.java b/mastodon/src/main/java/org/joinmastodon/android/googleservices/ConnectionResult.java
new file mode 100644
index 0000000000..774646eb66
--- /dev/null
+++ b/mastodon/src/main/java/org/joinmastodon/android/googleservices/ConnectionResult.java
@@ -0,0 +1,47 @@
+package org.joinmastodon.android.googleservices;
+
+import android.app.PendingIntent;
+
+import org.microg.safeparcel.AutoSafeParcelable;
+import org.microg.safeparcel.SafeParceled;
+
+public class ConnectionResult extends AutoSafeParcelable{
+ public static final int UNKNOWN = -1;
+ public static final int SUCCESS = 0;
+ public static final int SERVICE_MISSING = 1;
+ public static final int SERVICE_VERSION_UPDATE_REQUIRED = 2;
+ public static final int SERVICE_DISABLED = 3;
+ public static final int SIGN_IN_REQUIRED = 4;
+ public static final int INVALID_ACCOUNT = 5;
+ public static final int RESOLUTION_REQUIRED = 6;
+ public static final int NETWORK_ERROR = 7;
+ public static final int INTERNAL_ERROR = 8;
+ public static final int SERVICE_INVALID = 9;
+ public static final int DEVELOPER_ERROR = 10;
+ public static final int LICENSE_CHECK_FAILED = 11;
+ public static final int CANCELED = 13;
+ public static final int TIMEOUT = 14;
+ public static final int INTERRUPTED = 15;
+ public static final int API_UNAVAILABLE = 16;
+ public static final int SIGN_IN_FAILED = 17;
+ public static final int SERVICE_UPDATING = 18;
+ public static final int SERVICE_MISSING_PERMISSION = 19;
+ public static final int RESTRICTED_PROFILE = 20;
+ public static final int RESOLUTION_ACTIVITY_NOT_FOUND = 22;
+ public static final int API_DISABLED = 23;
+ public static final int API_DISABLED_FOR_CONNECTION = 24;
+ @Deprecated
+ public static final int DRIVE_EXTERNAL_STORAGE_REQUIRED = 1500;
+
+
+ @SafeParceled(1)
+ public int versionCode;
+ @SafeParceled(2)
+ public int errorCode;
+ @SafeParceled(3)
+ public PendingIntent resolution;
+ @SafeParceled(4)
+ public String errorMessage;
+
+ public static final Creator CREATOR=new AutoCreator<>(ConnectionResult.class);
+}
diff --git a/mastodon/src/main/java/org/joinmastodon/android/googleservices/GmsClient.java b/mastodon/src/main/java/org/joinmastodon/android/googleservices/GmsClient.java
new file mode 100644
index 0000000000..3b3c5e5a39
--- /dev/null
+++ b/mastodon/src/main/java/org/joinmastodon/android/googleservices/GmsClient.java
@@ -0,0 +1,116 @@
+package org.joinmastodon.android.googleservices;
+
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.content.ServiceConnection;
+import android.content.pm.PackageManager;
+import android.net.Uri;
+import android.os.Bundle;
+import android.os.IBinder;
+import android.os.IInterface;
+import android.os.RemoteException;
+import android.util.Log;
+import android.util.SparseArray;
+
+import com.google.android.gms.common.internal.ConnectionInfo;
+import com.google.android.gms.common.internal.GetServiceRequest;
+import com.google.android.gms.common.internal.IGmsCallbacks;
+import com.google.android.gms.common.internal.IGmsServiceBroker;
+import com.google.android.gms.common.moduleinstall.internal.IModuleInstallService;
+
+import java.util.function.Function;
+
+public class GmsClient{
+ private static final String TAG="GmsClient";
+ private static final SparseArray currentConnections=new SparseArray<>();
+ private static int nextConnectionID=0;
+
+ public static void connectToService(Context context, String action, int id, boolean useDynamicLookup, ServiceConnectionCallback callback, Function asInterface){
+ Intent intent;
+ if(useDynamicLookup){
+ try{
+ Bundle args=new Bundle();
+ args.putString("serviceActionBundleKey", action);
+ Bundle result=context.getContentResolver().call(new Uri.Builder().scheme("content").authority("com.google.android.gms.chimera").build(), "serviceIntentCall", null, args);
+ if(result==null)
+ throw new IllegalStateException("Dynamic lookup failed");
+ intent=result.getParcelable("serviceResponseIntentKey");
+ if(intent==null)
+ throw new IllegalStateException("Dynamic lookup returned null");
+ }catch(Exception x){
+ callback.onError(x);
+ return;
+ }
+ }else{
+ intent=new Intent(action);
+ }
+ intent.setPackage("com.google.android.gms");
+ ServiceConnection conn=new ServiceConnection(){
+ @Override
+ public void onServiceConnected(ComponentName name, IBinder service){
+ IGmsServiceBroker broker=IGmsServiceBroker.Stub.asInterface(service);
+ GetServiceRequest req=new GetServiceRequest();
+ req.serviceId=id;
+ req.packageName=context.getPackageName();
+ ServiceConnection serviceConnectionThis=this;
+ try{
+ broker.getService(new IGmsCallbacks.Stub(){
+ @Override
+ public void onPostInitComplete(int statusCode, IBinder binder, Bundle params) throws RemoteException{
+ int connectionID=nextConnectionID++;
+ currentConnections.put(connectionID, serviceConnectionThis);
+ callback.onSuccess(asInterface.apply(binder), connectionID);
+ }
+
+ @Override
+ public void onAccountValidationComplete(int statusCode, Bundle params) throws RemoteException{}
+
+ @Override
+ public void onPostInitCompleteWithConnectionInfo(int statusCode, IBinder binder, ConnectionInfo info) throws RemoteException{
+ onPostInitComplete(statusCode, binder, info!=null ? info.params : null);
+ }
+ }, req);
+ }catch(Exception x){
+ callback.onError(x);
+ context.unbindService(this);
+ }
+ }
+
+ @Override
+ public void onServiceDisconnected(ComponentName name){}
+ };
+ boolean res=context.bindService(intent, conn, Context.BIND_AUTO_CREATE | Context.BIND_DEBUG_UNBIND | Context.BIND_ADJUST_WITH_ACTIVITY);
+ if(!res){
+ context.unbindService(conn);
+ callback.onError(new IllegalStateException("Service connection failed"));
+ }
+ }
+
+ public static void disconnectFromService(Context context, int connectionID){
+ ServiceConnection conn=currentConnections.get(connectionID);
+ if(conn!=null){
+ currentConnections.remove(connectionID);
+ context.unbindService(conn);
+ }
+ }
+
+ public static boolean isGooglePlayServicesAvailable(Context context){
+ PackageManager pm=context.getPackageManager();
+ try{
+ pm.getPackageInfo("com.google.android.gms", 0);
+ return true;
+ }catch(PackageManager.NameNotFoundException e){
+ return false;
+ }
+ }
+
+ public static void getModuleInstallerService(Context context, ServiceConnectionCallback callback){
+ connectToService(context, "com.google.android.gms.chimera.container.moduleinstall.ModuleInstallService.START", 308, true, callback, IModuleInstallService.Stub::asInterface);
+ }
+
+ public interface ServiceConnectionCallback{
+ void onSuccess(I service, int connectionID);
+ void onError(Exception error);
+ }
+}
diff --git a/mastodon/src/main/java/org/joinmastodon/android/googleservices/barcodescanner/Barcode.java b/mastodon/src/main/java/org/joinmastodon/android/googleservices/barcodescanner/Barcode.java
new file mode 100644
index 0000000000..8cf0ff8415
--- /dev/null
+++ b/mastodon/src/main/java/org/joinmastodon/android/googleservices/barcodescanner/Barcode.java
@@ -0,0 +1,253 @@
+package org.joinmastodon.android.googleservices.barcodescanner;
+
+import android.graphics.Point;
+
+import org.microg.safeparcel.AutoSafeParcelable;
+import org.microg.safeparcel.SafeParceled;
+
+public class Barcode extends AutoSafeParcelable{
+ public static final int FORMAT_UNKNOWN = -1;
+ public static final int FORMAT_ALL_FORMATS = 0;
+ public static final int FORMAT_CODE_128 = 1;
+ public static final int FORMAT_CODE_39 = 2;
+ public static final int FORMAT_CODE_93 = 4;
+ public static final int FORMAT_CODABAR = 8;
+ public static final int FORMAT_DATA_MATRIX = 16;
+ public static final int FORMAT_EAN_13 = 32;
+ public static final int FORMAT_EAN_8 = 64;
+ public static final int FORMAT_ITF = 128;
+ public static final int FORMAT_QR_CODE = 256;
+ public static final int FORMAT_UPC_A = 512;
+ public static final int FORMAT_UPC_E = 1024;
+ public static final int FORMAT_PDF417 = 2048;
+ public static final int FORMAT_AZTEC = 4096;
+ public static final int TYPE_UNKNOWN = 0;
+ public static final int TYPE_CONTACT_INFO = 1;
+ public static final int TYPE_EMAIL = 2;
+ public static final int TYPE_ISBN = 3;
+ public static final int TYPE_PHONE = 4;
+ public static final int TYPE_PRODUCT = 5;
+ public static final int TYPE_SMS = 6;
+ public static final int TYPE_TEXT = 7;
+ public static final int TYPE_URL = 8;
+ public static final int TYPE_WIFI = 9;
+ public static final int TYPE_GEO = 10;
+ public static final int TYPE_CALENDAR_EVENT = 11;
+ public static final int TYPE_DRIVER_LICENSE = 12;
+
+ @SafeParceled(1)
+ public int format;
+ @SafeParceled(2)
+ public String displayValue;
+ @SafeParceled(3)
+ public String rawValue;
+ @SafeParceled(4)
+ public byte[] rawBytes;
+ @SafeParceled(5)
+ public Point[] cornerPoints;
+ @SafeParceled(6)
+ public int valueType;
+ @SafeParceled(7)
+ public Email emailValue;
+ @SafeParceled(8)
+ public Phone phoneValue;
+ @SafeParceled(9)
+ public SMS smsValue;
+ @SafeParceled(10)
+ public WiFi wifiValue;
+ @SafeParceled(11)
+ public UrlBookmark urlBookmarkValue;
+ @SafeParceled(12)
+ public GeoPoint geoPointValue;
+ @SafeParceled(13)
+ public CalendarEvent calendarEventValue;
+ @SafeParceled(14)
+ public ContactInfo contactInfoValue;
+ @SafeParceled(15)
+ public DriverLicense driverLicenseValue;
+
+ public static final Creator CREATOR=new AutoCreator<>(Barcode.class);
+
+ // None of the following is needed or used in the Mastodon app and its use cases for QR code scanning,
+ // but I'm putting it out there in case someone else is crazy enough to want to use Google Services without their libraries
+
+ public static class Email extends AutoSafeParcelable{
+ @SafeParceled(1)
+ public int type;
+ @SafeParceled(2)
+ public String address;
+ @SafeParceled(3)
+ public String subject;
+ @SafeParceled(4)
+ public String body;
+
+ public static final Creator CREATOR=new AutoCreator<>(Email.class);
+ }
+
+ public static class Phone extends AutoSafeParcelable{
+ @SafeParceled(1)
+ public int type;
+ @SafeParceled(2)
+ public String number;
+
+ public static final Creator CREATOR=new AutoCreator<>(Phone.class);
+ }
+
+ public static class SMS extends AutoSafeParcelable{
+ @SafeParceled(1)
+ public String message;
+ @SafeParceled(2)
+ public String phoneNumber;
+
+ public static final Creator CREATOR=new AutoCreator<>(SMS.class);
+ }
+
+ public static class WiFi extends AutoSafeParcelable{
+ @SafeParceled(1)
+ public String ssid;
+ @SafeParceled(2)
+ public String password;
+ @SafeParceled(3)
+ public int encryptionType;
+
+ public static final Creator CREATOR=new AutoCreator<>(WiFi.class);
+ }
+
+ public static class UrlBookmark extends AutoSafeParcelable{
+ @SafeParceled(1)
+ public String title;
+ @SafeParceled(2)
+ public String url;
+
+ public static final Creator CREATOR=new AutoCreator<>(UrlBookmark.class);
+ }
+
+ public static class GeoPoint extends AutoSafeParcelable{
+ @SafeParceled(1)
+ public double lat;
+ @SafeParceled(2)
+ public double lng;
+
+ public static final Creator CREATOR=new AutoCreator<>(GeoPoint.class);
+ }
+
+ public static class EventDateTime extends AutoSafeParcelable{
+ @SafeParceled(1)
+ public int year;
+ @SafeParceled(2)
+ public int month;
+ @SafeParceled(3)
+ public int day;
+ @SafeParceled(4)
+ public int hours;
+ @SafeParceled(5)
+ public int minutes;
+ @SafeParceled(6)
+ public int seconds;
+ @SafeParceled(7)
+ public boolean isUtc;
+ @SafeParceled(8)
+ public String rawValue;
+
+ public static final Creator CREATOR=new AutoCreator<>(EventDateTime.class);
+ }
+
+ public static class CalendarEvent extends AutoSafeParcelable{
+ @SafeParceled(1)
+ public String summary;
+ @SafeParceled(2)
+ public String description;
+ @SafeParceled(3)
+ public String location;
+ @SafeParceled(4)
+ public String organizer;
+ @SafeParceled(5)
+ public String status;
+ @SafeParceled(6)
+ public EventDateTime start;
+ @SafeParceled(7)
+ public EventDateTime end;
+
+ public static final Creator CREATOR=new AutoCreator<>(CalendarEvent.class);
+ }
+
+ public static class Address extends AutoSafeParcelable{
+ @SafeParceled(1)
+ public int type;
+ @SafeParceled(2)
+ public String[] addressLines;
+
+ public static final Creator CREATOR=new AutoCreator<>(Address.class);
+ }
+
+ public static class PersonName extends AutoSafeParcelable{
+ @SafeParceled(1)
+ public String formattedName;
+ @SafeParceled(2)
+ public String pronunciation;
+ @SafeParceled(3)
+ public String prefix;
+ @SafeParceled(4)
+ public String first;
+ @SafeParceled(5)
+ public String middle;
+ @SafeParceled(6)
+ public String last;
+ @SafeParceled(7)
+ public String suffix;
+
+ public static final Creator CREATOR=new AutoCreator<>(PersonName.class);
+ }
+
+ public static class ContactInfo extends AutoSafeParcelable{
+ @SafeParceled(1)
+ public PersonName name;
+ @SafeParceled(2)
+ public String organization;
+ @SafeParceled(3)
+ public String title;
+ @SafeParceled(4)
+ public Phone[] phones;
+ @SafeParceled(5)
+ public Email[] emails;
+ @SafeParceled(6)
+ public String[] urls;
+ @SafeParceled(7)
+ public Address[] addresses;
+
+ public static final Creator CREATOR=new AutoCreator<>(ContactInfo.class);
+ }
+
+ public static class DriverLicense extends AutoSafeParcelable{
+ @SafeParceled(1)
+ public String documentType;
+ @SafeParceled(2)
+ public String firstName;
+ @SafeParceled(3)
+ public String middleName;
+ @SafeParceled(4)
+ public String lastName;
+ @SafeParceled(5)
+ public String gender;
+ @SafeParceled(6)
+ public String addressStreet;
+ @SafeParceled(7)
+ public String addressCity;
+ @SafeParceled(8)
+ public String addressState;
+ @SafeParceled(9)
+ public String addressZip;
+ @SafeParceled(10)
+ public String licenseNumber;
+ @SafeParceled(11)
+ public String issueDate;
+ @SafeParceled(12)
+ public String expiryDate;
+ @SafeParceled(13)
+ public String birthDate;
+ @SafeParceled(14)
+ public String issuingCountry;
+
+ public static final Creator CREATOR=new AutoCreator<>(DriverLicense.class);
+ }
+}
diff --git a/mastodon/src/main/java/org/joinmastodon/android/googleservices/barcodescanner/BarcodeScanner.java b/mastodon/src/main/java/org/joinmastodon/android/googleservices/barcodescanner/BarcodeScanner.java
new file mode 100644
index 0000000000..8116729b40
--- /dev/null
+++ b/mastodon/src/main/java/org/joinmastodon/android/googleservices/barcodescanner/BarcodeScanner.java
@@ -0,0 +1,136 @@
+package org.joinmastodon.android.googleservices.barcodescanner;
+
+import android.app.ProgressDialog;
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.ApplicationInfo;
+import android.os.Parcel;
+import android.os.RemoteException;
+import android.util.Log;
+import android.widget.Toast;
+
+import com.google.android.gms.common.Feature;
+import com.google.android.gms.common.api.Status;
+import com.google.android.gms.common.moduleinstall.ModuleAvailabilityResponse;
+import com.google.android.gms.common.moduleinstall.ModuleInstallIntentResponse;
+import com.google.android.gms.common.moduleinstall.ModuleInstallResponse;
+import com.google.android.gms.common.moduleinstall.ModuleInstallStatusUpdate;
+import com.google.android.gms.common.moduleinstall.internal.ApiFeatureRequest;
+import com.google.android.gms.common.moduleinstall.internal.IModuleInstallCallbacks;
+import com.google.android.gms.common.moduleinstall.internal.IModuleInstallService;
+import com.google.android.gms.common.moduleinstall.internal.IModuleInstallStatusListener;
+
+import org.joinmastodon.android.MastodonApp;
+import org.joinmastodon.android.R;
+import org.joinmastodon.android.googleservices.GmsClient;
+import org.joinmastodon.android.ui.utils.UiUtils;
+
+import java.util.List;
+
+public class BarcodeScanner{
+ private static final String TAG="BarcodeScanner";
+
+ public static Intent createIntent(int formats, boolean allowManualInout, boolean enableAutoZoom){
+ Intent intent=new Intent().setPackage("com.google.android.gms").setAction("com.google.android.gms.mlkit.ACTION_SCAN_BARCODE");
+ String appName;
+ ApplicationInfo appInfo=MastodonApp.context.getApplicationInfo();
+ if(appInfo.labelRes!=0)
+ appName=MastodonApp.context.getString(appInfo.labelRes);
+ else
+ appName=MastodonApp.context.getPackageManager().getApplicationLabel(appInfo).toString();
+ intent.putExtra("extra_calling_app_name", appName);
+ intent.putExtra("extra_supported_formats", formats);
+ intent.putExtra("extra_allow_manual_input", allowManualInout);
+ intent.putExtra("extra_enable_auto_zoom", enableAutoZoom);
+ return intent;
+ }
+
+ public static boolean isValidResult(Intent intent){
+ return intent!=null && intent.hasExtra("extra_barcode_result");
+ }
+
+ public static Barcode getResult(Intent intent){
+ byte[] serialized=intent.getByteArrayExtra("extra_barcode_result");
+ Parcel parcel=Parcel.obtain();
+ parcel.unmarshall(serialized, 0, serialized.length);
+ parcel.setDataPosition(0);
+ Barcode barcode=Barcode.CREATOR.createFromParcel(parcel);
+ parcel.recycle();
+ return barcode;
+ }
+
+ public static void installScannerModule(Context context, Runnable onSuccess){
+ ProgressDialog progress=new ProgressDialog(context);
+ progress.setMessage(context.getString(R.string.loading));
+ progress.setCancelable(false);
+ progress.show();
+ GmsClient.getModuleInstallerService(context, new GmsClient.ServiceConnectionCallback<>(){
+ @Override
+ public void onSuccess(IModuleInstallService service, int connectionID){
+ ApiFeatureRequest req=new ApiFeatureRequest();
+ req.callingPackage=context.getPackageName();
+ Feature feature=new Feature();
+ feature.name="mlkit.barcode.ui";
+ feature.version=1;
+ feature.oldVersion=-1;
+ req.features=List.of(feature);
+ req.urgent=true;
+ try{
+ service.installModules(new IModuleInstallCallbacks.Stub(){
+ @Override
+ public void onModuleAvailabilityResponse(Status status, ModuleAvailabilityResponse response) throws RemoteException{}
+
+ @Override
+ public void onModuleInstallResponse(Status status, ModuleInstallResponse response) throws RemoteException{}
+
+ @Override
+ public void onModuleInstallIntentResponse(Status status, ModuleInstallIntentResponse response) throws RemoteException{}
+
+ @Override
+ public void onStatus(Status status) throws RemoteException{}
+ }, req, new IModuleInstallStatusListener.Stub(){
+ @Override
+ public void onModuleInstallStatusUpdate(ModuleInstallStatusUpdate statusUpdate) throws RemoteException{
+ if(statusUpdate.installState==ModuleInstallStatusUpdate.STATE_COMPLETED){
+ Intent scannerIntent=createIntent(0, false, false);
+ Runnable r=new Runnable(){
+ @Override
+ public void run(){
+ if(scannerIntent.resolveActivity(context.getPackageManager())!=null){
+ progress.dismiss();
+ onSuccess.run();
+ }else{
+ UiUtils.runOnUiThread(this, 100);
+ }
+ }
+ };
+ UiUtils.runOnUiThread(r);
+ GmsClient.disconnectFromService(context, connectionID);
+ }else if(statusUpdate.installState==ModuleInstallStatusUpdate.STATE_FAILED || statusUpdate.installState==ModuleInstallStatusUpdate.STATE_CANCELED){
+ UiUtils.runOnUiThread(()->{
+ progress.dismiss();
+ Toast.makeText(context, R.string.error, Toast.LENGTH_SHORT).show();
+ });
+ GmsClient.disconnectFromService(context, connectionID);
+ }
+ }
+ });
+ }catch(RemoteException e){
+ Log.e(TAG, "onSuccess: ", e);
+ UiUtils.runOnUiThread(()->{
+ progress.dismiss();
+ Toast.makeText(context, R.string.error, Toast.LENGTH_SHORT).show();
+ });
+ GmsClient.disconnectFromService(context, connectionID);
+ }
+ }
+
+ @Override
+ public void onError(Exception error){
+ Log.e(TAG, "onError() called with: error = ["+error+"]");
+ Toast.makeText(context, R.string.error, Toast.LENGTH_SHORT).show();
+ progress.dismiss();
+ }
+ });
+ }
+}
diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/drawables/FancyQrCodeDrawable.java b/mastodon/src/main/java/org/joinmastodon/android/ui/drawables/FancyQrCodeDrawable.java
new file mode 100644
index 0000000000..18025e327f
--- /dev/null
+++ b/mastodon/src/main/java/org/joinmastodon/android/ui/drawables/FancyQrCodeDrawable.java
@@ -0,0 +1,149 @@
+package org.joinmastodon.android.ui.drawables;
+
+import android.graphics.Canvas;
+import android.graphics.ColorFilter;
+import android.graphics.Matrix;
+import android.graphics.Paint;
+import android.graphics.Path;
+import android.graphics.PixelFormat;
+import android.graphics.Rect;
+import android.graphics.drawable.Drawable;
+
+import com.google.zxing.common.BitMatrix;
+
+import java.util.Arrays;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+public class FancyQrCodeDrawable extends Drawable{
+ private Paint paint=new Paint(Paint.ANTI_ALIAS_FLAG);
+ private Path path=new Path(), scaledPath=new Path();
+ private int size, logoOffset, logoSize;
+ private Drawable logo;
+
+ public FancyQrCodeDrawable(BitMatrix code, int color, Drawable logo){
+ paint.setColor(color);
+ this.logo=logo;
+ size=code.getWidth();
+ addMarker(0, 0);
+ addMarker(size-7, 0);
+ addMarker(0, size-7);
+ float[] radii=new float[8];
+ logoSize=size/3;
+ if((size-logoSize)%2!=0){
+ logoSize--;
+ }
+ logoOffset=(size-logoSize)/2;
+ for(int y=0;ysize-8 && y<7) || (x<7 && y>size-8)){
+ continue;
+ }
+
+ if(code.get(x, y)){
+ boolean t=y>0 && code.get(x, y-1);
+ boolean b=y0 && code.get(x-1, y);
+ boolean r=x=3 || (neighborCount==2 && ((l && r) || (t && b)))){ // 3 or 4 neighbors, or part of a straight line
+ path.addRect(x, y, x+1, y+1, Path.Direction.CW);
+ continue;
+ }else if(neighborCount==0){ // No neighbors
+ path.addCircle(x+0.5f, y+0.5f, 0.5f, Path.Direction.CW);
+ continue;
+ }
+ Arrays.fill(radii, 0);
+ if(l && t){ // round bottom-right corner
+ radii[4]=radii[5]=1;
+ }else if(t && r){ // round bottom-left corner
+ radii[6]=radii[7]=1;
+ }else if(r && b){ // round top-left corner
+ radii[0]=radii[1]=1;
+ }else if(b && l){ // round top-right corner
+ radii[2]=radii[3]=1;
+ }else if(l){ // right side
+ radii[2]=radii[3]=radii[4]=radii[5]=0.5f;
+ }else if(t){ // bottom side
+ radii[4]=radii[5]=radii[6]=radii[7]=0.5f;
+ }else if(r){ // left side
+ radii[6]=radii[7]=radii[1]=radii[0]=0.5f;
+ }else{ // top side
+ radii[0]=radii[1]=radii[2]=radii[3]=0.5f;
+ }
+ path.addRoundRect(x, y, x+1, y+1, radii, Path.Direction.CW);
+ }
+ }
+ }
+ }
+
+ private void addMarker(int x, int y){
+ path.addRoundRect(x, y, x+7, y+7, 2.38f, 2.38f, Path.Direction.CW);
+ path.addRoundRect(x+1, y+1, x+6, y+6, 1.33f, 1.33f, Path.Direction.CCW);
+ path.addRoundRect(x+2, y+2, x+5, y+5, 0.8f, 0.8f, Path.Direction.CW);
+ }
+
+ @Override
+ public void draw(@NonNull Canvas canvas){
+ Rect bounds=getBounds();
+ float factor=Math.min(bounds.width(), bounds.height())/(float)size;
+ float xOff=0, yOff=0;
+ float bw=bounds.width(), bh=bounds.height();
+ if(bw>bh){
+ xOff=bw/2f-bh/2f;
+ }else if(bw activeParticles=new ArrayList<>(), nextActiveParticles=new ArrayList<>(), pool=new ArrayList<>();
+ private int emitterX, emitterY;
+ private Paint paint=new Paint(Paint.ANTI_ALIAS_FLAG);
+ private float[] linearStartColor, linearEndColor;
+ private long prevFrameTime;
+ private Random rand=new Random();
+ private Rect clipOutBounds=new Rect();
+
+ public RadialParticleSystemDrawable(long particleLifetime, int birthRate, int startColor, int endColor, float velocity, float velocityVariance, float size){
+ this.particleLifetime=particleLifetime;
+ this.birthRate=birthRate;
+ this.startColor=startColor;
+ this.endColor=endColor;
+ this.velocity=velocity;
+ this.velocityVariance=velocityVariance;
+ this.size=size;
+
+ linearStartColor=new float[]{
+ ((startColor >> 24) & 0xFF)/255f,
+ (float)Math.pow(((startColor >> 16) & 0xFF)/255f, 2.2),
+ (float)Math.pow(((startColor >> 8) & 0xFF)/255f, 2.2),
+ (float)Math.pow((startColor & 0xFF)/255f, 2.2)
+ };
+ linearEndColor=new float[]{
+ ((endColor >> 24) & 0xFF)/255f,
+ (float)Math.pow(((endColor >> 16) & 0xFF)/255f, 2.2),
+ (float)Math.pow(((endColor >> 8) & 0xFF)/255f, 2.2),
+ (float)Math.pow((endColor & 0xFF)/255f, 2.2)
+ };
+ }
+
+ @Override
+ public void draw(@NonNull Canvas canvas){
+ long now=SystemClock.uptimeMillis();
+ nextActiveParticles.clear();
+ for(Particle p:activeParticles){
+ int time=(int)(now-p.birthTime);
+ if(time>particleLifetime){
+ pool.add(p);
+ continue;
+ }
+ nextActiveParticles.add(p);
+ float x=emitterX+time/1000f*p.velX;
+ float y=emitterY+time/1000f*p.velY;
+ if(clipOutBounds.contains((int)x, (int)y)){
+ continue;
+ }
+ float fraction=time/(float)particleLifetime;
+ paint.setColor(interpolateColor(fraction));
+ canvas.drawCircle(x, y, size, paint);
+ }
+ long timeDiff=Math.min(100, now-prevFrameTime);
+ int newParticleCount=Math.round(timeDiff/1000f*birthRate);
+ for(int i=0;i tmp=nextActiveParticles;
+ nextActiveParticles=activeParticles;
+ activeParticles=tmp;
+ invalidateSelf();
+ prevFrameTime=now;
+ }
+
+ @Override
+ public void setAlpha(int alpha){
+
+ }
+
+ @Override
+ public void setColorFilter(@Nullable ColorFilter colorFilter){
+
+ }
+
+ @Override
+ public int getOpacity(){
+ return PixelFormat.TRANSLUCENT;
+ }
+
+ public void setClipOutBounds(int l, int t, int r, int b){
+ clipOutBounds.set(l, t, r, b);
+ }
+
+ private int interpolateColor(float fraction){
+ float a=(linearStartColor[0]+(linearEndColor[0]-linearStartColor[0])*fraction)*255f;
+ float r=(float)Math.pow(linearStartColor[1]+(linearEndColor[1]-linearStartColor[1])*fraction, 1.0/2.2)*255f;
+ float g=(float)Math.pow(linearStartColor[2]+(linearEndColor[2]-linearStartColor[2])*fraction, 1.0/2.2)*255f;
+ float b=(float)Math.pow(linearStartColor[3]+(linearEndColor[3]-linearStartColor[3])*fraction, 1.0/2.2)*255f;
+ return (Math.round(a) << 24) | (Math.round(r) << 16) | (Math.round(g) << 8) | Math.round(b);
+
+ }
+
+ public void setEmitterPosition(int x, int y){
+ emitterX=x;
+ emitterY=y;
+ }
+
+ private static class Particle{
+ public long birthTime;
+ public float velX, velY;
+ }
+}
diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/photoviewer/PhotoViewer.java b/mastodon/src/main/java/org/joinmastodon/android/ui/photoviewer/PhotoViewer.java
index 5eda260059..55bce77c02 100644
--- a/mastodon/src/main/java/org/joinmastodon/android/ui/photoviewer/PhotoViewer.java
+++ b/mastodon/src/main/java/org/joinmastodon/android/ui/photoviewer/PhotoViewer.java
@@ -8,9 +8,12 @@
import android.annotation.SuppressLint;
import android.app.Activity;
import android.app.DownloadManager;
+import android.content.BroadcastReceiver;
import android.content.ContentResolver;
import android.content.ContentValues;
+import android.content.Context;
import android.content.Intent;
+import android.content.IntentFilter;
import android.content.pm.PackageManager;
import android.content.res.ColorStateList;
import android.graphics.Insets;
@@ -59,6 +62,7 @@
import org.joinmastodon.android.model.Attachment;
import org.joinmastodon.android.model.Status;
import org.joinmastodon.android.ui.M3AlertDialogBuilder;
+import org.joinmastodon.android.ui.Snackbar;
import org.joinmastodon.android.ui.utils.UiUtils;
import java.io.File;
@@ -126,6 +130,8 @@ public class PhotoViewer implements ZoomPanView.Listener{
private Runnable videoPositionUpdater=this::updateVideoPosition;
private int videoDuration, videoInitialPosition, videoLastTimeUpdatePosition;
private long videoInitialPositionTime;
+ private long lastDownloadID;
+ private boolean receiverRegistered;
private static final Property STATUS_BAR_COLOR_PROPERTY=new Property<>(Integer.class, "Fdsafdsa"){
@Override
@@ -139,6 +145,21 @@ public void set(FragmentRootLinearLayout object, Integer value){
}
};
+ private final BroadcastReceiver downloadCompletedReceiver=new BroadcastReceiver(){
+ @Override
+ public void onReceive(Context context, Intent intent){
+ long id=intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1);
+ if(id==lastDownloadID){
+ new Snackbar.Builder(activity)
+ .setText(R.string.video_saved)
+ .setAction(R.string.view_file, ()->activity.startActivity(new Intent(DownloadManager.ACTION_VIEW_DOWNLOADS)))
+ .show();
+ activity.unregisterReceiver(this);
+ receiverRegistered=false;
+ }
+ }
+ };
+
public PhotoViewer(Activity activity, List attachments, int index, Status status, String accountID, Listener listener){
this.activity=activity;
this.attachments=attachments.stream().filter(a->a.type==Attachment.Type.IMAGE || a.type==Attachment.Type.GIFV || a.type==Attachment.Type.VIDEO).collect(Collectors.toList());
@@ -392,6 +413,9 @@ public void onDismissed(){
listener.setPhotoViewVisibility(pager.getCurrentItem(), true);
wm.removeView(windowView);
listener.photoViewerDismissed();
+ if(receiverRegistered){
+ activity.unregisterReceiver(downloadCompletedReceiver);
+ }
}
@Override
@@ -581,7 +605,12 @@ private void doSaveCurrentFile(){
BufferedSink buf=Okio.buffer(sink);
buf.writeAll(src);
buf.flush();
- activity.runOnUiThread(()->Toast.makeText(activity, R.string.file_saved, Toast.LENGTH_SHORT).show());
+ activity.runOnUiThread(()->{
+ new Snackbar.Builder(activity)
+ .setText(R.string.image_saved)
+ .setAction(R.string.view_file, ()->activity.startActivity(new Intent(DownloadManager.ACTION_VIEW_DOWNLOADS)))
+ .show();
+ });
if(Build.VERSION.SDK_INT<29){
String fileName=Uri.parse(att.url).getLastPathSegment();
File dstFile=new File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), fileName);
@@ -589,12 +618,18 @@ private void doSaveCurrentFile(){
}
}catch(IOException x){
Log.w(TAG, "doSaveCurrentFile: ", x);
- activity.runOnUiThread(()->Toast.makeText(activity, R.string.error_saving_file, Toast.LENGTH_SHORT).show());
+ activity.runOnUiThread(()->{
+ new Snackbar.Builder(activity)
+ .setText(R.string.error_saving_file)
+ .show();
+ });
}
});
}catch(IOException x){
Log.w(TAG, "doSaveCurrentFile: ", x);
- Toast.makeText(activity, R.string.error_saving_file, Toast.LENGTH_SHORT).show();
+ new Snackbar.Builder(activity)
+ .setText(R.string.error_saving_file)
+ .show();
}
}else{
saveViaDownloadManager(att);
@@ -607,8 +642,12 @@ private void saveViaDownloadManager(Attachment att){
req.allowScanningByMediaScanner();
req.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED);
req.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, uri.getLastPathSegment());
- activity.getSystemService(DownloadManager.class).enqueue(req);
- Toast.makeText(activity, R.string.downloading, Toast.LENGTH_SHORT).show();
+ activity.registerReceiver(downloadCompletedReceiver, new IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE));
+ receiverRegistered=true;
+ lastDownloadID=activity.getSystemService(DownloadManager.class).enqueue(req);
+ new Snackbar.Builder(activity)
+ .setText(R.string.downloading)
+ .show();
}
private void shareAfterDownloading(Attachment att){
diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/views/FixedAspectRatioFrameLayout.java b/mastodon/src/main/java/org/joinmastodon/android/ui/views/FixedAspectRatioFrameLayout.java
new file mode 100644
index 0000000000..9952947ade
--- /dev/null
+++ b/mastodon/src/main/java/org/joinmastodon/android/ui/views/FixedAspectRatioFrameLayout.java
@@ -0,0 +1,57 @@
+package org.joinmastodon.android.ui.views;
+
+import android.content.Context;
+import android.content.res.TypedArray;
+import android.util.AttributeSet;
+import android.widget.FrameLayout;
+
+import org.joinmastodon.android.R;
+
+public class FixedAspectRatioFrameLayout extends FrameLayout{
+ private float aspectRatio;
+ private boolean useHeight;
+
+ public FixedAspectRatioFrameLayout(Context context){
+ this(context, null);
+ }
+
+ public FixedAspectRatioFrameLayout(Context context, AttributeSet attrs){
+ this(context, attrs, 0);
+ }
+
+ public FixedAspectRatioFrameLayout(Context context, AttributeSet attrs, int defStyle){
+ super(context, attrs, defStyle);
+ TypedArray ta=context.obtainStyledAttributes(attrs, R.styleable.FixedAspectRatioImageView);
+ aspectRatio=ta.getFloat(R.styleable.FixedAspectRatioImageView_aspectRatio, 1);
+ useHeight=ta.getBoolean(R.styleable.FixedAspectRatioImageView_useHeight, false);
+ ta.recycle();
+ }
+
+ @Override
+ protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec){
+ if(useHeight){
+ int height=MeasureSpec.getSize(heightMeasureSpec);
+ widthMeasureSpec=Math.round(height*aspectRatio) | MeasureSpec.EXACTLY;
+ }else{
+ int width=MeasureSpec.getSize(widthMeasureSpec);
+ heightMeasureSpec=Math.round(width/aspectRatio) | MeasureSpec.EXACTLY;
+ }
+ super.onMeasure(widthMeasureSpec, heightMeasureSpec);
+ }
+
+ public float getAspectRatio(){
+ return aspectRatio;
+ }
+
+ public void setAspectRatio(float aspectRatio){
+ this.aspectRatio=aspectRatio;
+ }
+
+ public boolean isUseHeight(){
+ return useHeight;
+ }
+
+ public void setUseHeight(boolean useHeight){
+ this.useHeight=useHeight;
+ }
+}
diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/wrapstodon/RoundedImageView.java b/mastodon/src/main/java/org/joinmastodon/android/ui/wrapstodon/RoundedImageView.java
new file mode 100644
index 0000000000..1483b6c3df
--- /dev/null
+++ b/mastodon/src/main/java/org/joinmastodon/android/ui/wrapstodon/RoundedImageView.java
@@ -0,0 +1,74 @@
+package org.joinmastodon.android.ui.wrapstodon;
+
+import android.content.Context;
+import android.content.res.TypedArray;
+import android.graphics.Canvas;
+import android.graphics.Outline;
+import android.graphics.Paint;
+import android.graphics.PorterDuff;
+import android.graphics.PorterDuffXfermode;
+import android.util.AttributeSet;
+import android.view.View;
+import android.view.ViewOutlineProvider;
+import android.widget.ImageView;
+
+import org.joinmastodon.android.R;
+
+/**
+ * Software-rendering-friendly rounded-corners image view. Relies on arcane xrefmode magic.
+ */
+public class RoundedImageView extends ImageView{
+ private int cornerRadius;
+ private boolean roundBottomCorners=true;
+ private Paint clearPaint=new Paint(Paint.ANTI_ALIAS_FLAG), paint=new Paint(Paint.ANTI_ALIAS_FLAG);
+
+ public RoundedImageView(Context context){
+ this(context, null);
+ }
+
+ public RoundedImageView(Context context, AttributeSet attrs){
+ this(context, attrs, 0);
+ }
+
+ public RoundedImageView(Context context, AttributeSet attrs, int defStyle){
+ super(context, attrs, defStyle);
+ TypedArray ta=context.obtainStyledAttributes(attrs, R.styleable.RoundedImageView);
+ cornerRadius=ta.getDimensionPixelOffset(R.styleable.RoundedImageView_cornerRadius, 0);
+ roundBottomCorners=ta.getBoolean(R.styleable.RoundedImageView_roundBottomCorners, true);
+ ta.recycle();
+ setOutlineProvider(new ViewOutlineProvider(){
+ @Override
+ public void getOutline(View view, Outline outline){
+ outline.setRoundRect(0, 0, view.getWidth(), view.getHeight()+(roundBottomCorners ? 0 : cornerRadius), cornerRadius);
+ }
+ });
+ setClipToOutline(true);
+ clearPaint.setColor(0xFFFFFFFF);
+ clearPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.SRC_IN));
+ paint.setColor(0xFF0ff000);
+ }
+
+ public void setCornerRadius(int cornerRadius){
+ this.cornerRadius=cornerRadius;
+ invalidateOutline();
+ }
+
+ public void setRoundBottomCorners(boolean roundBottomCorners){
+ this.roundBottomCorners=roundBottomCorners;
+ invalidateOutline();
+ }
+
+ @Override
+ public void draw(Canvas canvas){
+ if(canvas.isHardwareAccelerated()){
+ super.draw(canvas);
+ return;
+ }
+ canvas.saveLayer(0, 0, getWidth(), getHeight(), null);
+ canvas.drawRoundRect(0, 0, getWidth(), getHeight()+(roundBottomCorners ? 0 : cornerRadius), cornerRadius, cornerRadius, paint);
+ canvas.saveLayer(0, 0, getWidth(), getHeight(), clearPaint);
+ super.draw(canvas);
+ canvas.restore();
+ canvas.restore();
+ }
+}
diff --git a/mastodon/src/main/res/drawable/ic_fluent_barcode_scanner_24_filled.xml b/mastodon/src/main/res/drawable/ic_fluent_barcode_scanner_24_filled.xml
new file mode 100644
index 0000000000..a80988499a
--- /dev/null
+++ b/mastodon/src/main/res/drawable/ic_fluent_barcode_scanner_24_filled.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/mastodon/src/main/res/drawable/ic_fluent_qr_code_24_filled.xml b/mastodon/src/main/res/drawable/ic_fluent_qr_code_24_filled.xml
new file mode 100644
index 0000000000..27473e3ca2
--- /dev/null
+++ b/mastodon/src/main/res/drawable/ic_fluent_qr_code_24_filled.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
diff --git a/mastodon/src/main/res/drawable/rect_24dp.xml b/mastodon/src/main/res/drawable/rect_24dp.xml
new file mode 100644
index 0000000000..6fbd53fb6c
--- /dev/null
+++ b/mastodon/src/main/res/drawable/rect_24dp.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/mastodon/src/main/res/layout/fragment_discover.xml b/mastodon/src/main/res/layout/fragment_discover.xml
index f39392c586..708c4f5f21 100644
--- a/mastodon/src/main/res/layout/fragment_discover.xml
+++ b/mastodon/src/main/res/layout/fragment_discover.xml
@@ -29,8 +29,9 @@
+
+
diff --git a/mastodon/src/main/res/layout/fragment_profile.xml b/mastodon/src/main/res/layout/fragment_profile.xml
index 083f129d78..782aa7c08d 100644
--- a/mastodon/src/main/res/layout/fragment_profile.xml
+++ b/mastodon/src/main/res/layout/fragment_profile.xml
@@ -24,6 +24,7 @@
android:orientation="vertical">
@@ -88,7 +89,8 @@
android:layout_height="wrap_content"
android:minHeight="48dp"
android:layout_marginTop="16dp"
- android:layout_marginEnd="4dp">
+ android:layout_marginEnd="4dp"
+ android:layout_weight="1">
+ android:layout_marginEnd="4dp">
+
+
+
+
+
diff --git a/mastodon/src/main/res/layout/fragment_profile_qr.xml b/mastodon/src/main/res/layout/fragment_profile_qr.xml
new file mode 100644
index 0000000000..324242444d
--- /dev/null
+++ b/mastodon/src/main/res/layout/fragment_profile_qr.xml
@@ -0,0 +1,148 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/mastodon/src/main/res/layout/profile_qr_toolbar.xml b/mastodon/src/main/res/layout/profile_qr_toolbar.xml
new file mode 100644
index 0000000000..16457ad0e9
--- /dev/null
+++ b/mastodon/src/main/res/layout/profile_qr_toolbar.xml
@@ -0,0 +1,7 @@
+
+
\ No newline at end of file