diff --git a/mastodon/build.gradle b/mastodon/build.gradle index 1e38bdd096..509232b512 100644 --- a/mastodon/build.gradle +++ b/mastodon/build.gradle @@ -134,6 +134,9 @@ android { buildFeatures { buildConfig true } + buildFeatures{ + aidl true + } } dependencies { @@ -151,6 +154,8 @@ dependencies { implementation 'org.jsoup:jsoup:1.14.3' implementation 'com.squareup:otto:1.3.8' implementation 'de.psdev:async-otto:1.0.3' + implementation 'com.google.zxing:core:3.5.3' + implementation 'org.microg:safe-parcel:1.5.0' implementation 'org.parceler:parceler-api:1.1.12' implementation 'com.github.bottom-software-foundation:bottom-java:2.1.0' annotationProcessor 'org.parceler:parceler:1.1.12' diff --git a/mastodon/src/main/AndroidManifest.xml b/mastodon/src/main/AndroidManifest.xml index 08ab4ec893..2e70b43836 100644 --- a/mastodon/src/main/AndroidManifest.xml +++ b/mastodon/src/main/AndroidManifest.xml @@ -39,6 +39,10 @@ android:windowSoftInputMode="adjustPan" android:largeHeap="true"> + + diff --git a/mastodon/src/main/aidl/com/google/android/gms/common/api/Status.aidl b/mastodon/src/main/aidl/com/google/android/gms/common/api/Status.aidl new file mode 100644 index 0000000000..701f99a914 --- /dev/null +++ b/mastodon/src/main/aidl/com/google/android/gms/common/api/Status.aidl @@ -0,0 +1,3 @@ +package com.google.android.gms.common.api; + +parcelable Status; \ No newline at end of file diff --git a/mastodon/src/main/aidl/com/google/android/gms/common/api/internal/IStatusCallback.aidl b/mastodon/src/main/aidl/com/google/android/gms/common/api/internal/IStatusCallback.aidl new file mode 100644 index 0000000000..6355a13b4a --- /dev/null +++ b/mastodon/src/main/aidl/com/google/android/gms/common/api/internal/IStatusCallback.aidl @@ -0,0 +1,7 @@ +package com.google.android.gms.common.api.internal; + +import com.google.android.gms.common.api.Status; + +interface IStatusCallback { + void onResult(in Status status); +} diff --git a/mastodon/src/main/aidl/com/google/android/gms/common/internal/ConnectionInfo.aidl b/mastodon/src/main/aidl/com/google/android/gms/common/internal/ConnectionInfo.aidl new file mode 100644 index 0000000000..b393f11d0e --- /dev/null +++ b/mastodon/src/main/aidl/com/google/android/gms/common/internal/ConnectionInfo.aidl @@ -0,0 +1,7 @@ +/* + * SPDX-FileCopyrightText: 2020, microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.android.gms.common.internal; +parcelable ConnectionInfo; diff --git a/mastodon/src/main/aidl/com/google/android/gms/common/internal/GetServiceRequest.aidl b/mastodon/src/main/aidl/com/google/android/gms/common/internal/GetServiceRequest.aidl new file mode 100644 index 0000000000..791dbe907f --- /dev/null +++ b/mastodon/src/main/aidl/com/google/android/gms/common/internal/GetServiceRequest.aidl @@ -0,0 +1,3 @@ +package com.google.android.gms.common.internal; + +parcelable GetServiceRequest; diff --git a/mastodon/src/main/aidl/com/google/android/gms/common/internal/IGmsCallbacks.aidl b/mastodon/src/main/aidl/com/google/android/gms/common/internal/IGmsCallbacks.aidl new file mode 100644 index 0000000000..8fe63347d6 --- /dev/null +++ b/mastodon/src/main/aidl/com/google/android/gms/common/internal/IGmsCallbacks.aidl @@ -0,0 +1,15 @@ +/* + * SPDX-FileCopyrightText: 2020, microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.android.gms.common.internal; + +import android.os.Bundle; +import com.google.android.gms.common.internal.ConnectionInfo; + +interface IGmsCallbacks { + void onPostInitComplete(int statusCode, IBinder binder, in Bundle params); + void onAccountValidationComplete(int statusCode, in Bundle params); + void onPostInitCompleteWithConnectionInfo(int statusCode, IBinder binder, in ConnectionInfo info); +} diff --git a/mastodon/src/main/aidl/com/google/android/gms/common/internal/IGmsServiceBroker.aidl b/mastodon/src/main/aidl/com/google/android/gms/common/internal/IGmsServiceBroker.aidl new file mode 100644 index 0000000000..a4b747e8fe --- /dev/null +++ b/mastodon/src/main/aidl/com/google/android/gms/common/internal/IGmsServiceBroker.aidl @@ -0,0 +1,10 @@ +package com.google.android.gms.common.internal; + +import android.os.Bundle; + +import com.google.android.gms.common.internal.IGmsCallbacks; +import com.google.android.gms.common.internal.GetServiceRequest; + +interface IGmsServiceBroker { + void getService(IGmsCallbacks callback, in GetServiceRequest request) = 45; +} \ No newline at end of file diff --git a/mastodon/src/main/aidl/com/google/android/gms/common/moduleinstall/ModuleAvailabilityResponse.aidl b/mastodon/src/main/aidl/com/google/android/gms/common/moduleinstall/ModuleAvailabilityResponse.aidl new file mode 100644 index 0000000000..b44d217427 --- /dev/null +++ b/mastodon/src/main/aidl/com/google/android/gms/common/moduleinstall/ModuleAvailabilityResponse.aidl @@ -0,0 +1,3 @@ +package com.google.android.gms.common.moduleinstall; + +parcelable ModuleAvailabilityResponse; diff --git a/mastodon/src/main/aidl/com/google/android/gms/common/moduleinstall/ModuleInstallIntentResponse.aidl b/mastodon/src/main/aidl/com/google/android/gms/common/moduleinstall/ModuleInstallIntentResponse.aidl new file mode 100644 index 0000000000..18bcfa6e2b --- /dev/null +++ b/mastodon/src/main/aidl/com/google/android/gms/common/moduleinstall/ModuleInstallIntentResponse.aidl @@ -0,0 +1,3 @@ +package com.google.android.gms.common.moduleinstall; + +parcelable ModuleInstallIntentResponse; diff --git a/mastodon/src/main/aidl/com/google/android/gms/common/moduleinstall/ModuleInstallResponse.aidl b/mastodon/src/main/aidl/com/google/android/gms/common/moduleinstall/ModuleInstallResponse.aidl new file mode 100644 index 0000000000..d5b31230a6 --- /dev/null +++ b/mastodon/src/main/aidl/com/google/android/gms/common/moduleinstall/ModuleInstallResponse.aidl @@ -0,0 +1,3 @@ +package com.google.android.gms.common.moduleinstall; + +parcelable ModuleInstallResponse; diff --git a/mastodon/src/main/aidl/com/google/android/gms/common/moduleinstall/ModuleInstallStatusUpdate.aidl b/mastodon/src/main/aidl/com/google/android/gms/common/moduleinstall/ModuleInstallStatusUpdate.aidl new file mode 100644 index 0000000000..1669f5eaec --- /dev/null +++ b/mastodon/src/main/aidl/com/google/android/gms/common/moduleinstall/ModuleInstallStatusUpdate.aidl @@ -0,0 +1,3 @@ +package com.google.android.gms.common.moduleinstall; + +parcelable ModuleInstallStatusUpdate; diff --git a/mastodon/src/main/aidl/com/google/android/gms/common/moduleinstall/internal/ApiFeatureRequest.aidl b/mastodon/src/main/aidl/com/google/android/gms/common/moduleinstall/internal/ApiFeatureRequest.aidl new file mode 100644 index 0000000000..2f8571f291 --- /dev/null +++ b/mastodon/src/main/aidl/com/google/android/gms/common/moduleinstall/internal/ApiFeatureRequest.aidl @@ -0,0 +1,3 @@ +package com.google.android.gms.common.moduleinstall.internal; + +parcelable ApiFeatureRequest; diff --git a/mastodon/src/main/aidl/com/google/android/gms/common/moduleinstall/internal/IModuleInstallCallbacks.aidl b/mastodon/src/main/aidl/com/google/android/gms/common/moduleinstall/internal/IModuleInstallCallbacks.aidl new file mode 100644 index 0000000000..39f51ca491 --- /dev/null +++ b/mastodon/src/main/aidl/com/google/android/gms/common/moduleinstall/internal/IModuleInstallCallbacks.aidl @@ -0,0 +1,13 @@ +package com.google.android.gms.common.moduleinstall.internal; + +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; + +interface IModuleInstallCallbacks { + void onModuleAvailabilityResponse(in Status status, in ModuleAvailabilityResponse response) = 0; + void onModuleInstallResponse(in Status status, in ModuleInstallResponse response) = 1; + void onModuleInstallIntentResponse(in Status status, in ModuleInstallIntentResponse response) = 2; + void onStatus(in Status status) = 3; +} diff --git a/mastodon/src/main/aidl/com/google/android/gms/common/moduleinstall/internal/IModuleInstallService.aidl b/mastodon/src/main/aidl/com/google/android/gms/common/moduleinstall/internal/IModuleInstallService.aidl new file mode 100644 index 0000000000..7737eceda6 --- /dev/null +++ b/mastodon/src/main/aidl/com/google/android/gms/common/moduleinstall/internal/IModuleInstallService.aidl @@ -0,0 +1,14 @@ +package com.google.android.gms.common.moduleinstall.internal; + +import com.google.android.gms.common.api.internal.IStatusCallback; +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.IModuleInstallStatusListener; + +interface IModuleInstallService { + void areModulesAvailable(IModuleInstallCallbacks callbacks, in ApiFeatureRequest request) = 0; + void installModules(IModuleInstallCallbacks callbacks, in ApiFeatureRequest request, IModuleInstallStatusListener listener) = 1; + void getInstallModulesIntent(IModuleInstallCallbacks callbacks, in ApiFeatureRequest request) = 2; + void releaseModules(IStatusCallback callback, in ApiFeatureRequest request) = 3; + void unregisterListener(IStatusCallback callback, IModuleInstallStatusListener listener) = 5; +} diff --git a/mastodon/src/main/aidl/com/google/android/gms/common/moduleinstall/internal/IModuleInstallStatusListener.aidl b/mastodon/src/main/aidl/com/google/android/gms/common/moduleinstall/internal/IModuleInstallStatusListener.aidl new file mode 100644 index 0000000000..250599035a --- /dev/null +++ b/mastodon/src/main/aidl/com/google/android/gms/common/moduleinstall/internal/IModuleInstallStatusListener.aidl @@ -0,0 +1,7 @@ +package com.google.android.gms.common.moduleinstall.internal; + +import com.google.android.gms.common.moduleinstall.ModuleInstallStatusUpdate; + +interface IModuleInstallStatusListener { + void onModuleInstallStatusUpdate(in ModuleInstallStatusUpdate statusUpdate) = 0; +} diff --git a/mastodon/src/main/java/com/google/android/gms/common/Feature.java b/mastodon/src/main/java/com/google/android/gms/common/Feature.java new file mode 100644 index 0000000000..01bb5b6741 --- /dev/null +++ b/mastodon/src/main/java/com/google/android/gms/common/Feature.java @@ -0,0 +1,15 @@ +package com.google.android.gms.common; + +import org.microg.safeparcel.AutoSafeParcelable; +import org.microg.safeparcel.SafeParceled; + +public class Feature extends AutoSafeParcelable{ + @SafeParceled(1) + public String name; + @SafeParceled(2) + public int oldVersion; + @SafeParceled(3) + public long version=-1; + + public static final Creator CREATOR=new AutoCreator<>(Feature.class); +} diff --git a/mastodon/src/main/java/com/google/android/gms/common/api/Scope.java b/mastodon/src/main/java/com/google/android/gms/common/api/Scope.java new file mode 100644 index 0000000000..5f78394253 --- /dev/null +++ b/mastodon/src/main/java/com/google/android/gms/common/api/Scope.java @@ -0,0 +1,13 @@ +package com.google.android.gms.common.api; + +import org.microg.safeparcel.AutoSafeParcelable; +import org.microg.safeparcel.SafeParceled; + +public class Scope extends AutoSafeParcelable{ + @SafeParceled(1) + public int versionCode=1; + @SafeParceled(2) + public String scopeUri; + + public static final Creator CREATOR=new AutoCreator<>(Scope.class); +} diff --git a/mastodon/src/main/java/com/google/android/gms/common/api/Status.java b/mastodon/src/main/java/com/google/android/gms/common/api/Status.java new file mode 100644 index 0000000000..a029b9992e --- /dev/null +++ b/mastodon/src/main/java/com/google/android/gms/common/api/Status.java @@ -0,0 +1,33 @@ +package com.google.android.gms.common.api; + +import android.app.PendingIntent; + +import org.joinmastodon.android.googleservices.ConnectionResult; +import org.microg.safeparcel.AutoSafeParcelable; +import org.microg.safeparcel.SafeParceled; + +public class Status extends AutoSafeParcelable{ + @SafeParceled(1000) + public int versionCode; + @SafeParceled(1) + public int statusCode; + @SafeParceled(2) + public String statusMessage; + @SafeParceled(3) + public PendingIntent pendingIntent; + @SafeParceled(4) + public ConnectionResult connectionResult; + + public static final Creator CREATOR=new AutoCreator<>(Status.class); + + @Override + public String toString(){ + return "Status{"+ + "versionCode="+versionCode+ + ", statusCode="+statusCode+ + ", statusMessage='"+statusMessage+'\''+ + ", pendingIntent="+pendingIntent+ + ", connectionResult="+connectionResult+ + '}'; + } +} diff --git a/mastodon/src/main/java/com/google/android/gms/common/internal/ConnectionInfo.java b/mastodon/src/main/java/com/google/android/gms/common/internal/ConnectionInfo.java new file mode 100644 index 0000000000..2cb2c4e130 --- /dev/null +++ b/mastodon/src/main/java/com/google/android/gms/common/internal/ConnectionInfo.java @@ -0,0 +1,19 @@ +package com.google.android.gms.common.internal; + +import android.os.Bundle; + +import com.google.android.gms.common.Feature; + +import org.microg.safeparcel.AutoSafeParcelable; +import org.microg.safeparcel.SafeParceled; + +public class ConnectionInfo extends AutoSafeParcelable{ + @SafeParceled(1) + public Bundle params; + @SafeParceled(2) + public Feature[] features; + @SafeParceled(3) + public int unknown3; + + public static final Creator CREATOR=new AutoCreator<>(ConnectionInfo.class); +} diff --git a/mastodon/src/main/java/com/google/android/gms/common/internal/GetServiceRequest.java b/mastodon/src/main/java/com/google/android/gms/common/internal/GetServiceRequest.java new file mode 100644 index 0000000000..976c85854f --- /dev/null +++ b/mastodon/src/main/java/com/google/android/gms/common/internal/GetServiceRequest.java @@ -0,0 +1,47 @@ +package com.google.android.gms.common.internal; + +import android.os.Bundle; +import android.os.IBinder; +import android.accounts.Account; + +import com.google.android.gms.common.Feature; +import com.google.android.gms.common.api.Scope; + +import org.microg.safeparcel.AutoSafeParcelable; +import org.microg.safeparcel.SafeParceled; + +public class GetServiceRequest extends AutoSafeParcelable{ + @SafeParceled(1) + int versionCode=6; + @SafeParceled(2) + public int serviceId; + @SafeParceled(3) + public int gmsVersion; + @SafeParceled(4) + public String packageName; + @SafeParceled(5) + public IBinder accountAccessor; + @SafeParceled(6) + public Scope[] scopes; + @SafeParceled(7) + public Bundle extras; + @SafeParceled(8) + public Account account; + @SafeParceled(9) + @Deprecated + long field9; + @SafeParceled(10) + public Feature[] defaultFeatures; + @SafeParceled(11) + public Feature[] apiFeatures; + @SafeParceled(12) + boolean supportsConnectionInfo; + @SafeParceled(13) + int field13; + @SafeParceled(14) + boolean field14; + @SafeParceled(15) + String attributionTag; + + public static final Creator CREATOR=new AutoCreator<>(GetServiceRequest.class); +} diff --git a/mastodon/src/main/java/com/google/android/gms/common/moduleinstall/ModuleAvailabilityResponse.java b/mastodon/src/main/java/com/google/android/gms/common/moduleinstall/ModuleAvailabilityResponse.java new file mode 100644 index 0000000000..8c5c924fef --- /dev/null +++ b/mastodon/src/main/java/com/google/android/gms/common/moduleinstall/ModuleAvailabilityResponse.java @@ -0,0 +1,13 @@ +package com.google.android.gms.common.moduleinstall; + +import org.microg.safeparcel.AutoSafeParcelable; +import org.microg.safeparcel.SafeParceled; + +public class ModuleAvailabilityResponse extends AutoSafeParcelable{ + @SafeParceled(1) + public boolean modulesAvailable; + @SafeParceled(2) + public int availabilityStatus; + + public static final Creator CREATOR=new AutoCreator<>(ModuleAvailabilityResponse.class); +} diff --git a/mastodon/src/main/java/com/google/android/gms/common/moduleinstall/ModuleInstallIntentResponse.java b/mastodon/src/main/java/com/google/android/gms/common/moduleinstall/ModuleInstallIntentResponse.java new file mode 100644 index 0000000000..d1b25e47a0 --- /dev/null +++ b/mastodon/src/main/java/com/google/android/gms/common/moduleinstall/ModuleInstallIntentResponse.java @@ -0,0 +1,13 @@ +package com.google.android.gms.common.moduleinstall; + +import android.app.PendingIntent; + +import org.microg.safeparcel.AutoSafeParcelable; +import org.microg.safeparcel.SafeParceled; + +public class ModuleInstallIntentResponse extends AutoSafeParcelable{ + @SafeParceled(1) + public PendingIntent pendingIntent; + + public static final Creator CREATOR=new AutoCreator<>(ModuleInstallIntentResponse.class); +} diff --git a/mastodon/src/main/java/com/google/android/gms/common/moduleinstall/ModuleInstallResponse.java b/mastodon/src/main/java/com/google/android/gms/common/moduleinstall/ModuleInstallResponse.java new file mode 100644 index 0000000000..bde197ae51 --- /dev/null +++ b/mastodon/src/main/java/com/google/android/gms/common/moduleinstall/ModuleInstallResponse.java @@ -0,0 +1,21 @@ +package com.google.android.gms.common.moduleinstall; + +import org.microg.safeparcel.AutoSafeParcelable; +import org.microg.safeparcel.SafeParceled; + +public class ModuleInstallResponse extends AutoSafeParcelable{ + @SafeParceled(1) + public int sessionID; + @SafeParceled(2) + public boolean shouldUnregisterListener; + + public static final Creator 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