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 000000000..c0a52379d
--- /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 000000000..4ff5e38c1
--- /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 900319fbe..a9c8438d5 100644
--- a/mastodon/src/main/java/org/joinmastodon/android/fragments/ProfileFragment.java
+++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/ProfileFragment.java
@@ -23,8 +23,10 @@ import android.text.TextUtils;
import android.text.style.ImageSpan;
import android.transition.ChangeBounds;
import android.transition.Fade;
+import android.transition.Transition;
import android.transition.TransitionManager;
import android.transition.TransitionSet;
+import android.util.Log;
import android.view.LayoutInflater;
import android.view.Menu;
import android.view.MenuInflater;
@@ -36,6 +38,7 @@ import android.view.ViewTreeObserver;
import android.view.WindowInsets;
import android.widget.EditText;
import android.widget.FrameLayout;
+import android.widget.ImageButton;
import android.widget.ImageView;
import android.widget.LinearLayout;
import android.widget.ProgressBar;
@@ -129,6 +132,7 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList
private View tabsDivider;
private View actionButtonWrap;
private CustomDrawingOrderLinearLayout scrollableContent;
+ private ImageButton qrCodeButton;
private Account account;
private String accountID;
@@ -211,6 +215,7 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList
tabsDivider=content.findViewById(R.id.tabs_divider);
actionButtonWrap=content.findViewById(R.id.profile_action_btn_wrap);
scrollableContent=content.findViewById(R.id.scrollable_content);
+ qrCodeButton=content.findViewById(R.id.qr_code);
avatar.setOutlineProvider(OutlineProviders.roundedRect(24));
avatar.setClipToOutline(true);
@@ -324,6 +329,14 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList
bioEdit.addTextChangedListener(new SimpleTextWatcher(e->editDirty=true));
usernameDomain.setOnClickListener(v->new DecentralizationExplainerSheet(getActivity(), accountID, account).show());
+ 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;
}
@@ -836,17 +849,48 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList
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);
- 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);
@@ -885,11 +929,40 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList
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);
@@ -898,6 +971,8 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList
bio.setVisibility(View.VISIBLE);
countersLayout.setVisibility(View.VISIBLE);
refreshLayout.setEnabled(true);
+ usernameDomain.setVisibility(View.VISIBLE);
+ qrCodeButton.setVisibility(View.VISIBLE);
bindHeaderView();
V.setVisibilityAnimated(fab, View.VISIBLE);
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 000000000..4c7a1a01c
--- /dev/null
+++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/ProfileQrCodeFragment.java
@@ -0,0 +1,583 @@
+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.app.Activity;
+import android.app.Dialog;
+import android.app.ProgressDialog;
+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.RemoteException;
+import android.os.SystemClock;
+import android.provider.MediaStore;
+import android.provider.Settings;
+import android.text.TextUtils;
+import android.util.AttributeSet;
+import android.util.Log;
+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.View;
+import android.view.ViewGroup;
+import android.view.ViewTreeObserver;
+import android.view.WindowManager;
+import android.widget.Button;
+import android.widget.ImageView;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+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 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.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 java.util.UUID;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+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 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);
+ getActivity().setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED);
+ }
+
+ @Override
+ public void onAttach(Activity activity){
+ super.onAttach(activity);
+ themeWrapper=new ContextThemeWrapper(activity, R.style.Theme_Mastodon_Dark);
+ }
+
+ @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);
+ Button save=content.findViewById(R.id.save_btn);
+ 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);
+ 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)));
+ });
+ save.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);
+
+ 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);
+ }
+
+ @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_qr_code_scanner_24px);
+ }
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(MenuItem item){
+ if(scannerIntent.resolveActivity(getActivity().getPackageManager())!=null){
+ startActivityForResult(scannerIntent, SCAN_RESULT);
+ }else{
+ ProgressDialog progress=new ProgressDialog(getActivity());
+ progress.setMessage(getString(R.string.loading));
+ progress.setCancelable(false);
+ progress.show();
+ GmsClient.getModuleInstallerService(getActivity(), new GmsClient.ServiceConnectionCallback<>(){
+ @Override
+ public void onSuccess(IModuleInstallService service, int connectionID){
+ ApiFeatureRequest req=new ApiFeatureRequest();
+ req.callingPackage=getActivity().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){
+ Runnable r=new Runnable(){
+ @Override
+ public void run(){
+ if(scannerIntent.resolveActivity(getActivity().getPackageManager())!=null){
+ progress.dismiss();
+ startActivityForResult(scannerIntent, SCAN_RESULT);
+ }else{
+ codeContainer.postDelayed(this, 100);
+ }
+ }
+ };
+ getActivity().runOnUiThread(r);
+ GmsClient.disconnectFromService(getActivity(), connectionID);
+ }else if(statusUpdate.installState==ModuleInstallStatusUpdate.STATE_FAILED || statusUpdate.installState==ModuleInstallStatusUpdate.STATE_CANCELED){
+ getActivity().runOnUiThread(()->{
+ progress.dismiss();
+ Toast.makeText(themeWrapper, R.string.error, Toast.LENGTH_SHORT).show();
+ });
+ GmsClient.disconnectFromService(getActivity(), connectionID);
+ }
+ }
+ });
+ }catch(RemoteException e){
+ Log.e(TAG, "onSuccess: ", e);
+ getActivity().runOnUiThread(()->{
+ progress.dismiss();
+ Toast.makeText(themeWrapper, R.string.error, Toast.LENGTH_SHORT).show();
+ });
+ GmsClient.disconnectFromService(getActivity(), connectionID);
+ }
+ }
+
+ @Override
+ public void onError(Exception error){
+ Log.e(TAG, "onError() called with: error = ["+error+"]");
+ Toast.makeText(themeWrapper, R.string.error, Toast.LENGTH_SHORT).show();
+ progress.dismiss();
+ }
+ });
+ }
+ 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_baseline_close_24;
+ }
+
+ @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){
+ if(currentTransition!=null)
+ currentTransition.cancel();
+ AnimatorSet set=new AnimatorSet();
+ set.playTogether(
+ ObjectAnimator.ofInt(scrim, "alpha", 0),
+ ObjectAnimator.ofFloat(particleAnimContainer, View.TRANSLATION_Y, V.dp(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(()->Toast.makeText(activity, R.string.file_saved, Toast.LENGTH_SHORT).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(()->Toast.makeText(activity, R.string.error_saving_file, Toast.LENGTH_SHORT).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)openReply());
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/googleservices/ConnectionResult.java b/mastodon/src/main/java/org/joinmastodon/android/googleservices/ConnectionResult.java
new file mode 100644
index 000000000..774646eb6
--- /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 000000000..3b3c5e5a3
--- /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 000000000..8cf0ff841
--- /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 000000000..40de1f60f
--- /dev/null
+++ b/mastodon/src/main/java/org/joinmastodon/android/googleservices/barcodescanner/BarcodeScanner.java
@@ -0,0 +1,38 @@
+package org.joinmastodon.android.googleservices.barcodescanner;
+
+import android.content.Intent;
+import android.content.pm.ApplicationInfo;
+import android.os.Parcel;
+
+import org.joinmastodon.android.MastodonApp;
+
+public class 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;
+ }
+}
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 000000000..18025e327
--- /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/views/FixedAspectRatioFrameLayout.java b/mastodon/src/main/java/org/joinmastodon/android/ui/views/FixedAspectRatioFrameLayout.java
new file mode 100644
index 000000000..9952947ad
--- /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 000000000..1483b6c3d
--- /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_download_20px.xml b/mastodon/src/main/res/drawable/ic_download_20px.xml
new file mode 100644
index 000000000..7267a460f
--- /dev/null
+++ b/mastodon/src/main/res/drawable/ic_download_20px.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/mastodon/src/main/res/drawable/ic_qr_code_20px.xml b/mastodon/src/main/res/drawable/ic_qr_code_20px.xml
new file mode 100644
index 000000000..bff65fba4
--- /dev/null
+++ b/mastodon/src/main/res/drawable/ic_qr_code_20px.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/mastodon/src/main/res/drawable/ic_qr_code_scanner_24px.xml b/mastodon/src/main/res/drawable/ic_qr_code_scanner_24px.xml
new file mode 100644
index 000000000..4b0c8c109
--- /dev/null
+++ b/mastodon/src/main/res/drawable/ic_qr_code_scanner_24px.xml
@@ -0,0 +1,9 @@
+
+
+
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 000000000..6fbd53fb6
--- /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_profile.xml b/mastodon/src/main/res/layout/fragment_profile.xml
index c72f56259..e5b7bdca1 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">
@@ -291,8 +292,9 @@
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1">
+
+
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 000000000..a1da70535
--- /dev/null
+++ b/mastodon/src/main/res/layout/fragment_profile_qr.xml
@@ -0,0 +1,132 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ 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 000000000..16457ad0e
--- /dev/null
+++ b/mastodon/src/main/res/layout/profile_qr_toolbar.xml
@@ -0,0 +1,7 @@
+
+
\ No newline at end of file
diff --git a/mastodon/src/main/res/values/attrs.xml b/mastodon/src/main/res/values/attrs.xml
index 0a2748ba4..a81b290d8 100644
--- a/mastodon/src/main/res/values/attrs.xml
+++ b/mastodon/src/main/res/values/attrs.xml
@@ -63,4 +63,9 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/mastodon/src/main/res/values/strings.xml b/mastodon/src/main/res/values/strings.xml
index d616d9202..a5e575aa0 100644
--- a/mastodon/src/main/res/values/strings.xml
+++ b/mastodon/src/main/res/values/strings.xml
@@ -702,4 +702,6 @@
What’s ActivityPub?
ActivityPub is like the language Mastodon speaks with other social networks.\n\nIt lets you connect and interact with people not just on Mastodon, but across different social apps too.
Handle copied to clipboard.
+ QR code
+ Scan QR code
\ No newline at end of file