From 27d919e6b2328419e83e4218ff1e29481592e351 Mon Sep 17 00:00:00 2001 From: Ammar Githam Date: Wed, 30 Jun 2021 00:21:10 +0900 Subject: [PATCH] Migrate ProfileFragment to kotlin and use viewmodel --- .../instagrabber/activities/MainActivity.kt | 79 +- .../customviews/RamboTextViewV2.java | 7 + .../dialogs/MultiOptionDialogFragment.java | 58 +- .../fragments/StoryViewerFragment.java | 11 +- .../DirectMessageSettingsFragment.kt | 6 +- .../fragments/main/ProfileFragment.java | 1351 ----------------- .../fragments/main/ProfileFragment.kt | 981 ++++++++++++ .../managers/DirectMessagesManager.kt | 18 +- .../instagrabber/managers/InboxManager.kt | 13 +- .../instagrabber/managers/ThreadManager.kt | 61 +- ...Repository.kt => DirectMessagesService.kt} | 2 +- .../responses/UserProfileContextLink.java | 21 - .../responses/UserProfileContextLink.kt | 7 + .../java/awais/instagrabber/utils/Event.kt | 27 + .../instagrabber/utils/SettingsHelper.kt | 29 +- .../instagrabber/utils/SingleLiveEvent.kt | 68 + .../utils/extensions/StringExtensions.kt | 3 + .../utils/extensions/UserExtensions.kt | 9 + .../viewmodels/AppStateViewModel.java | 25 +- .../viewmodels/PostViewV2ViewModel.kt | 6 +- .../viewmodels/ProfileFragmentViewModel.kt | 503 +++++- .../viewmodels/UserSearchViewModel.java | 10 +- ...Service.kt => DirectMessagesRepository.kt} | 85 +- .../webservices/MediaRepository.kt | 7 +- .../webservices/RetrofitFactory.kt | 2 +- app/src/main/res/layout/fragment_profile.xml | 3 +- app/src/main/res/values/strings.xml | 1 + .../xml/profile_fragment_no_acc_layout.xml | 28 + .../awais/instagrabber/common/Adapters.kt | 116 ++ .../ProfileFragmentViewModelTest.kt | 107 +- 30 files changed, 2024 insertions(+), 1620 deletions(-) delete mode 100644 app/src/main/java/awais/instagrabber/fragments/main/ProfileFragment.java create mode 100644 app/src/main/java/awais/instagrabber/fragments/main/ProfileFragment.kt rename app/src/main/java/awais/instagrabber/repositories/{DirectMessagesRepository.kt => DirectMessagesService.kt} (99%) delete mode 100644 app/src/main/java/awais/instagrabber/repositories/responses/UserProfileContextLink.java create mode 100644 app/src/main/java/awais/instagrabber/repositories/responses/UserProfileContextLink.kt create mode 100644 app/src/main/java/awais/instagrabber/utils/Event.kt create mode 100644 app/src/main/java/awais/instagrabber/utils/SingleLiveEvent.kt create mode 100644 app/src/main/java/awais/instagrabber/utils/extensions/StringExtensions.kt create mode 100644 app/src/main/java/awais/instagrabber/utils/extensions/UserExtensions.kt rename app/src/main/java/awais/instagrabber/webservices/{DirectMessagesService.kt => DirectMessagesRepository.kt} (86%) create mode 100644 app/src/main/res/xml/profile_fragment_no_acc_layout.xml diff --git a/app/src/main/java/awais/instagrabber/activities/MainActivity.kt b/app/src/main/java/awais/instagrabber/activities/MainActivity.kt index 32e39c38..fc7b4a2c 100644 --- a/app/src/main/java/awais/instagrabber/activities/MainActivity.kt +++ b/app/src/main/java/awais/instagrabber/activities/MainActivity.kt @@ -111,8 +111,10 @@ class MainActivity : BaseLanguageActivity(), FragmentManager.OnBackStackChangedL override fun onCreate(savedInstanceState: Bundle?) { try { - DownloadUtils.init(this, - Utils.settingsHelper.getString(PreferenceKeys.PREF_BARINSTA_DIR_URI)) + DownloadUtils.init( + this, + Utils.settingsHelper.getString(PreferenceKeys.PREF_BARINSTA_DIR_URI) + ) } catch (e: ReselectDocumentTreeException) { super.onCreate(savedInstanceState) val intent = Intent(this, DirectorySelectActivity::class.java) @@ -324,6 +326,7 @@ class MainActivity : BaseLanguageActivity(), FragmentManager.OnBackStackChangedL // } catch (e: Exception) { // Log.e(TAG, "onDestroy: ", e) // } + DownloadUtils.destroy() instance = null } @@ -358,21 +361,27 @@ class MainActivity : BaseLanguageActivity(), FragmentManager.OnBackStackChangedL private fun createNotificationChannels() { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return val notificationManager = NotificationManagerCompat.from(applicationContext) - notificationManager.createNotificationChannel(NotificationChannel( - Constants.DOWNLOAD_CHANNEL_ID, - Constants.DOWNLOAD_CHANNEL_NAME, - NotificationManager.IMPORTANCE_DEFAULT - )) - notificationManager.createNotificationChannel(NotificationChannel( - Constants.ACTIVITY_CHANNEL_ID, - Constants.ACTIVITY_CHANNEL_NAME, - NotificationManager.IMPORTANCE_DEFAULT - )) - notificationManager.createNotificationChannel(NotificationChannel( - Constants.DM_UNREAD_CHANNEL_ID, - Constants.DM_UNREAD_CHANNEL_NAME, - NotificationManager.IMPORTANCE_DEFAULT - )) + notificationManager.createNotificationChannel( + NotificationChannel( + Constants.DOWNLOAD_CHANNEL_ID, + Constants.DOWNLOAD_CHANNEL_NAME, + NotificationManager.IMPORTANCE_DEFAULT + ) + ) + notificationManager.createNotificationChannel( + NotificationChannel( + Constants.ACTIVITY_CHANNEL_ID, + Constants.ACTIVITY_CHANNEL_NAME, + NotificationManager.IMPORTANCE_DEFAULT + ) + ) + notificationManager.createNotificationChannel( + NotificationChannel( + Constants.DM_UNREAD_CHANNEL_ID, + Constants.DM_UNREAD_CHANNEL_NAME, + NotificationManager.IMPORTANCE_DEFAULT + ) + ) val silentNotificationChannel = NotificationChannel( Constants.SILENT_NOTIFICATIONS_CHANNEL_ID, Constants.SILENT_NOTIFICATIONS_CHANNEL_NAME, @@ -404,7 +413,8 @@ class MainActivity : BaseLanguageActivity(), FragmentManager.OnBackStackChangedL supportFragmentManager, R.id.main_nav_host, intent, - firstFragmentGraphIndex) + firstFragmentGraphIndex + ) navControllerLiveData.observe(this, { navController: NavController? -> setupNavigation(binding.toolbar, navController) }) currentNavControllerLiveData = navControllerLiveData } @@ -432,27 +442,33 @@ class MainActivity : BaseLanguageActivity(), FragmentManager.OnBackStackChangedL private fun setupAnonBottomNav(): List { val selectedItemId = binding.bottomNavView.selectedItemId - val favoriteTab = Tab(R.drawable.ic_star_24, + val favoriteTab = Tab( + R.drawable.ic_star_24, getString(R.string.title_favorites), false, "favorites_nav_graph", R.navigation.favorites_nav_graph, R.id.favorites_nav_graph, - R.id.favoritesFragment) - val profileTab = Tab(R.drawable.ic_person_24, + R.id.favoritesFragment + ) + val profileTab = Tab( + R.drawable.ic_person_24, getString(R.string.profile), false, "profile_nav_graph", R.navigation.profile_nav_graph, R.id.profile_nav_graph, - R.id.profileFragment) - val moreTab = Tab(R.drawable.ic_more_horiz_24, + R.id.profileFragment + ) + val moreTab = Tab( + R.drawable.ic_more_horiz_24, getString(R.string.more), false, "more_nav_graph", R.navigation.more_nav_graph, R.id.more_nav_graph, - R.id.morePreferencesFragment) + R.id.morePreferencesFragment + ) val menu = binding.bottomNavView.menu menu.clear() menu.add(0, favoriteTab.navigationRootId, 0, favoriteTab.title).setIcon(favoriteTab.iconResId) @@ -489,9 +505,15 @@ class MainActivity : BaseLanguageActivity(), FragmentManager.OnBackStackChangedL if (destination.id == R.id.directMessagesThreadFragment && arguments != null) { // Set the thread title earlier for better ux val title = arguments.getString("title") - val actionBar = supportActionBar - if (actionBar != null && !isEmpty(title)) { - actionBar.title = title + if (!title.isNullOrBlank()) { + supportActionBar?.title = title + } + } + if (destination.id == R.id.profileFragment && arguments != null) { + // Set the title to username + val username = arguments.getString("username") + if (!username.isNullOrBlank()) { + supportActionBar?.title = username.substringAfter("@") } } // below is a hack to check if we are at the end of the current stack, to setup the search view @@ -764,7 +786,8 @@ class MainActivity : BaseLanguageActivity(), FragmentManager.OnBackStackChangedL "com.google.android.gms.fonts", "com.google.android.gms", "Noto Color Emoji Compat", - R.array.com_google_android_gms_fonts_certs) + R.array.com_google_android_gms_fonts_certs + ) val config: EmojiCompat.Config = FontRequestEmojiCompatConfig(applicationContext, fontRequest) config.setReplaceAll(true) // .setUseEmojiAsDefaultStyle(true) .registerInitCallback(object : InitCallback() { diff --git a/app/src/main/java/awais/instagrabber/customviews/RamboTextViewV2.java b/app/src/main/java/awais/instagrabber/customviews/RamboTextViewV2.java index a96766af..3fc467e7 100644 --- a/app/src/main/java/awais/instagrabber/customviews/RamboTextViewV2.java +++ b/app/src/main/java/awais/instagrabber/customviews/RamboTextViewV2.java @@ -156,6 +156,13 @@ public class RamboTextViewV2 extends AutoLinkTextView { onEmailClickListeners.clear(); } + public void clearAllAutoLinkListeners() { + clearOnMentionClickListeners(); + clearOnHashtagClickListeners(); + clearOnURLClickListeners(); + clearOnEmailClickListeners(); + } + public interface OnMentionClickListener { void onMentionClick(final AutoLinkItem autoLinkItem); } diff --git a/app/src/main/java/awais/instagrabber/dialogs/MultiOptionDialogFragment.java b/app/src/main/java/awais/instagrabber/dialogs/MultiOptionDialogFragment.java index e220155a..a1468b09 100644 --- a/app/src/main/java/awais/instagrabber/dialogs/MultiOptionDialogFragment.java +++ b/app/src/main/java/awais/instagrabber/dialogs/MultiOptionDialogFragment.java @@ -10,6 +10,8 @@ import androidx.annotation.NonNull; import androidx.annotation.StringRes; import androidx.appcompat.app.AlertDialog; import androidx.fragment.app.DialogFragment; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentActivity; import com.google.android.material.dialog.MaterialAlertDialogBuilder; import com.google.common.primitives.Booleans; @@ -36,18 +38,21 @@ public class MultiOptionDialogFragment extends DialogFra private List> options; @NonNull - public static MultiOptionDialogFragment newInstance(@StringRes final int title, + public static MultiOptionDialogFragment newInstance(final int requestCode, + @StringRes final int title, @NonNull final ArrayList> options) { - return newInstance(title, 0, 0, options, Type.SINGLE); + return newInstance(requestCode, title, 0, 0, options, Type.SINGLE); } @NonNull - public static MultiOptionDialogFragment newInstance(@StringRes final int title, + public static MultiOptionDialogFragment newInstance(final int requestCode, + @StringRes final int title, @StringRes final int positiveButtonText, @StringRes final int negativeButtonText, @NonNull final ArrayList> options, @NonNull final Type type) { Bundle args = new Bundle(); + args.putInt("requestCode", requestCode); args.putInt("title", title); args.putInt("positiveButtonText", positiveButtonText); args.putInt("negativeButtonText", negativeButtonText); @@ -58,10 +63,28 @@ public class MultiOptionDialogFragment extends DialogFra return fragment; } + @SuppressWarnings({"rawtypes", "unchecked"}) @Override public void onAttach(@NonNull final Context context) { super.onAttach(context); this.context = context; + final Fragment parentFragment = getParentFragment(); + if (parentFragment != null) { + if (parentFragment instanceof MultiOptionDialogCallback) { + callback = (MultiOptionDialogCallback) parentFragment; + } + if (parentFragment instanceof MultiOptionDialogSingleCallback) { + singleCallback = (MultiOptionDialogSingleCallback) parentFragment; + } + return; + } + final FragmentActivity fragmentActivity = getActivity(); + if (fragmentActivity instanceof MultiOptionDialogCallback) { + callback = (MultiOptionDialogCallback) fragmentActivity; + } + if (fragmentActivity instanceof MultiOptionDialogSingleCallback) { + singleCallback = (MultiOptionDialogSingleCallback) fragmentActivity; + } } @NonNull @@ -69,12 +92,15 @@ public class MultiOptionDialogFragment extends DialogFra public Dialog onCreateDialog(Bundle savedInstanceState) { final Bundle arguments = getArguments(); int title = 0; + int rc = 0; if (arguments != null) { + rc = arguments.getInt("requestCode"); title = arguments.getInt("title"); type = (Type) arguments.getSerializable("type"); } + final int requestCode = rc; final MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(context); - if (title > 0) { + if (title != 0) { builder.setTitle(title); } try { @@ -89,11 +115,11 @@ public class MultiOptionDialogFragment extends DialogFra if (negativeButtonText > 0) { builder.setNegativeButton(negativeButtonText, (dialog, which) -> { if (callback != null) { - callback.onCancel(); + callback.onCancel(requestCode); return; } if (singleCallback != null) { - singleCallback.onCancel(); + singleCallback.onCancel(requestCode); } }); } @@ -113,7 +139,7 @@ public class MultiOptionDialogFragment extends DialogFra final Option option = (Option) options.get(position); selected.add(option.value); } - callback.onMultipleSelect(selected); + callback.onMultipleSelect(requestCode, selected); } catch (Exception e) { Log.e(TAG, "onCreateDialog: ", e); } @@ -133,7 +159,7 @@ public class MultiOptionDialogFragment extends DialogFra try { final Option option = options.get(which); //noinspection unchecked - callback.onCheckChange((T) option.value, isChecked); + callback.onCheckChange(requestCode, (T) option.value, isChecked); } catch (Exception e) { Log.e(TAG, "onCreateDialog: ", e); } @@ -157,7 +183,7 @@ public class MultiOptionDialogFragment extends DialogFra try { final Option option = options.get(which); //noinspection unchecked - callback.onCheckChange((T) option.value, true); + callback.onCheckChange(requestCode, (T) option.value, true); } catch (Exception e) { Log.e(TAG, "onCreateDialog: ", e); } @@ -168,7 +194,7 @@ public class MultiOptionDialogFragment extends DialogFra try { final Option option = options.get(which); //noinspection unchecked - singleCallback.onSelect((T) option.value); + singleCallback.onSelect(requestCode, (T) option.value); } catch (Exception e) { Log.e(TAG, "onCreateDialog: ", e); } @@ -190,19 +216,19 @@ public class MultiOptionDialogFragment extends DialogFra } public interface MultiOptionDialogCallback { - void onSelect(T result); + void onSelect(int requestCode, T result); - void onMultipleSelect(List result); + void onMultipleSelect(int requestCode, List result); - void onCheckChange(T item, boolean isChecked); + void onCheckChange(int requestCode, T item, boolean isChecked); - void onCancel(); + void onCancel(int requestCode); } public interface MultiOptionDialogSingleCallback { - void onSelect(T result); + void onSelect(int requestCode, T result); - void onCancel(); + void onCancel(int requestCode); } public static class Option { diff --git a/app/src/main/java/awais/instagrabber/fragments/StoryViewerFragment.java b/app/src/main/java/awais/instagrabber/fragments/StoryViewerFragment.java index b24ad0f2..4a1334d1 100644 --- a/app/src/main/java/awais/instagrabber/fragments/StoryViewerFragment.java +++ b/app/src/main/java/awais/instagrabber/fragments/StoryViewerFragment.java @@ -2,7 +2,6 @@ package awais.instagrabber.fragments; import android.annotation.SuppressLint; import android.content.Context; -import android.content.pm.PackageManager; import android.graphics.drawable.Animatable; import android.net.Uri; import android.os.Bundle; @@ -31,8 +30,6 @@ import androidx.annotation.Nullable; import androidx.appcompat.app.ActionBar; import androidx.appcompat.app.AlertDialog; import androidx.appcompat.app.AppCompatActivity; -import androidx.core.app.ActivityCompat; -import androidx.core.content.ContextCompat; import androidx.core.view.GestureDetectorCompat; import androidx.fragment.app.Fragment; import androidx.lifecycle.ViewModel; @@ -98,7 +95,7 @@ import awais.instagrabber.viewmodels.ArchivesViewModel; import awais.instagrabber.viewmodels.FeedStoriesViewModel; import awais.instagrabber.viewmodels.HighlightsViewModel; import awais.instagrabber.viewmodels.StoriesViewModel; -import awais.instagrabber.webservices.DirectMessagesService; +import awais.instagrabber.webservices.DirectMessagesRepository; import awais.instagrabber.webservices.MediaRepository; import awais.instagrabber.webservices.ServiceCallback; import awais.instagrabber.webservices.StoriesRepository; @@ -148,7 +145,7 @@ public class StoryViewerFragment extends Fragment { // private boolean isHighlight; // private boolean isArchive; // private boolean isNotification; - private DirectMessagesService directMessagesService; + private DirectMessagesRepository directMessagesRepository; private StoryViewerOptions options; private String csrfToken; private String deviceId; @@ -164,7 +161,7 @@ public class StoryViewerFragment extends Fragment { fragmentActivity = (AppCompatActivity) requireActivity(); storiesRepository = StoriesRepository.Companion.getInstance(); mediaRepository = MediaRepository.Companion.getInstance(); - directMessagesService = DirectMessagesService.INSTANCE; + directMessagesRepository = DirectMessagesRepository.Companion.getInstance(); setHasOptionsMenu(true); } @@ -218,7 +215,7 @@ public class StoryViewerFragment extends Fragment { final AlertDialog ad = new AlertDialog.Builder(context) .setTitle(R.string.reply_story) .setView(input) - .setPositiveButton(R.string.confirm, (d, w) -> directMessagesService.broadcastStoryReply( + .setPositiveButton(R.string.confirm, (d, w) -> directMessagesRepository.broadcastStoryReply( csrfToken, userId, deviceId, diff --git a/app/src/main/java/awais/instagrabber/fragments/directmessages/DirectMessageSettingsFragment.kt b/app/src/main/java/awais/instagrabber/fragments/directmessages/DirectMessageSettingsFragment.kt index 0cb6a474..ee39b885 100644 --- a/app/src/main/java/awais/instagrabber/fragments/directmessages/DirectMessageSettingsFragment.kt +++ b/app/src/main/java/awais/instagrabber/fragments/directmessages/DirectMessageSettingsFragment.kt @@ -308,9 +308,9 @@ class DirectMessageSettingsFragment : Fragment(), ConfirmDialogFragmentCallback { _: Int, user: User? -> val options = viewModel.createUserOptions(user) if (options.isEmpty()) return@DirectUsersAdapter true - val fragment = MultiOptionDialogFragment.newInstance(-1, options) + val fragment = MultiOptionDialogFragment.newInstance(0, -1, options) fragment.setSingleCallback(object : MultiOptionDialogSingleCallback { - override fun onSelect(action: String?) { + override fun onSelect(requestCode: Int, action: String?) { if (action == null) return val resourceLiveData = viewModel.doAction(user, action) if (resourceLiveData != null) { @@ -318,7 +318,7 @@ class DirectMessageSettingsFragment : Fragment(), ConfirmDialogFragmentCallback } } - override fun onCancel() {} + override fun onCancel(requestCode: Int) {} }) val fragmentManager = childFragmentManager fragment.show(fragmentManager, "actions") diff --git a/app/src/main/java/awais/instagrabber/fragments/main/ProfileFragment.java b/app/src/main/java/awais/instagrabber/fragments/main/ProfileFragment.java deleted file mode 100644 index 1f2d7638..00000000 --- a/app/src/main/java/awais/instagrabber/fragments/main/ProfileFragment.java +++ /dev/null @@ -1,1351 +0,0 @@ -package awais.instagrabber.fragments.main; - -import android.content.Context; -import android.content.DialogInterface; -import android.content.Intent; -import android.graphics.Typeface; -import android.os.Bundle; -import android.os.Handler; -import android.os.Looper; -import android.text.SpannableStringBuilder; -import android.text.style.RelativeSizeSpan; -import android.text.style.StyleSpan; -import android.util.Log; -import android.view.ActionMode; -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.widget.Toast; - -import androidx.activity.OnBackPressedCallback; -import androidx.activity.OnBackPressedDispatcher; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.appcompat.app.ActionBar; -import androidx.appcompat.app.AlertDialog; -import androidx.appcompat.content.res.AppCompatResources; -import androidx.constraintlayout.motion.widget.MotionLayout; -import androidx.constraintlayout.motion.widget.MotionScene; -import androidx.fragment.app.Fragment; -import androidx.fragment.app.FragmentManager; -import androidx.fragment.app.FragmentTransaction; -import androidx.lifecycle.MutableLiveData; -import androidx.lifecycle.Observer; -import androidx.lifecycle.ViewModelProvider; -import androidx.navigation.NavBackStackEntry; -import androidx.navigation.NavController; -import androidx.navigation.NavDirections; -import androidx.navigation.fragment.NavHostFragment; -import androidx.recyclerview.widget.LinearLayoutManager; -import androidx.recyclerview.widget.RecyclerView; -import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; - -import com.google.android.material.snackbar.BaseTransientBottomBar; -import com.google.android.material.snackbar.Snackbar; -import com.google.common.collect.ImmutableList; - -import java.time.LocalDateTime; -import java.util.Collections; -import java.util.List; -import java.util.Objects; -import java.util.Set; - -import awais.instagrabber.R; -import awais.instagrabber.UserSearchNavGraphDirections; -import awais.instagrabber.activities.MainActivity; -import awais.instagrabber.adapters.FeedAdapterV2; -import awais.instagrabber.adapters.HighlightsAdapter; -import awais.instagrabber.asyncs.ProfilePostFetchService; -import awais.instagrabber.customviews.PrimaryActionModeCallback; -import awais.instagrabber.customviews.PrimaryActionModeCallback.CallbacksHelper; -import awais.instagrabber.databinding.FragmentProfileBinding; -import awais.instagrabber.databinding.LayoutProfileDetailsBinding; -import awais.instagrabber.db.entities.Favorite; -import awais.instagrabber.db.repositories.AccountRepository; -import awais.instagrabber.db.repositories.FavoriteRepository; -import awais.instagrabber.dialogs.PostsLayoutPreferencesDialogFragment; -import awais.instagrabber.dialogs.ProfilePicDialogFragment; -import awais.instagrabber.fragments.PostViewV2Fragment; -import awais.instagrabber.fragments.UserSearchFragment; -import awais.instagrabber.fragments.UserSearchFragmentDirections; -import awais.instagrabber.managers.DirectMessagesManager; -import awais.instagrabber.managers.InboxManager; -import awais.instagrabber.models.HighlightModel; -import awais.instagrabber.models.PostsLayoutPreferences; -import awais.instagrabber.models.enums.FavoriteType; -import awais.instagrabber.models.enums.PostItemType; -import awais.instagrabber.repositories.requests.StoryViewerOptions; -import awais.instagrabber.repositories.responses.FriendshipChangeResponse; -import awais.instagrabber.repositories.responses.FriendshipStatus; -import awais.instagrabber.repositories.responses.Media; -import awais.instagrabber.repositories.responses.User; -import awais.instagrabber.repositories.responses.UserProfileContextLink; -import awais.instagrabber.repositories.responses.directmessages.RankedRecipient; -import awais.instagrabber.utils.AppExecutors; -import awais.instagrabber.utils.Constants; -import awais.instagrabber.utils.CookieUtils; -import awais.instagrabber.utils.CoroutineUtilsKt; -import awais.instagrabber.utils.DownloadUtils; -import awais.instagrabber.utils.TextUtils; -import awais.instagrabber.utils.Utils; -import awais.instagrabber.viewmodels.AppStateViewModel; -import awais.instagrabber.viewmodels.HighlightsViewModel; -import awais.instagrabber.viewmodels.ProfileFragmentViewModel; -import awais.instagrabber.viewmodels.ProfileFragmentViewModelFactory; -import awais.instagrabber.webservices.DirectMessagesService; -import awais.instagrabber.webservices.FriendshipRepository; -import awais.instagrabber.webservices.GraphQLRepository; -import awais.instagrabber.webservices.MediaRepository; -import awais.instagrabber.webservices.ServiceCallback; -import awais.instagrabber.webservices.StoriesRepository; -import awais.instagrabber.webservices.UserRepository; -import kotlinx.coroutines.Dispatchers; - -import static awais.instagrabber.fragments.HashTagFragment.ARG_HASHTAG; - -public class ProfileFragment extends Fragment implements SwipeRefreshLayout.OnRefreshListener { - private static final String TAG = "ProfileFragment"; - - private MainActivity fragmentActivity; - private MotionLayout root; - private FragmentProfileBinding binding; - private boolean isLoggedIn; - private String cookie; - private String username; - private User profileModel; - private ActionMode actionMode; - private Handler usernameSettingHandler; - private FriendshipRepository friendshipRepository; - private StoriesRepository storiesRepository; - private MediaRepository mediaRepository; - private UserRepository userRepository; - private GraphQLRepository graphQLRepository; - private DirectMessagesService directMessagesService; - private boolean shouldRefresh = true; - private boolean hasStories = false; - private HighlightsAdapter highlightsAdapter; - private HighlightsViewModel highlightsViewModel; - private MenuItem blockMenuItem, restrictMenuItem, chainingMenuItem; - private MenuItem muteStoriesMenuItem, mutePostsMenuItem, removeFollowerMenuItem; - private MenuItem shareLinkMenuItem, shareDmMenuItem; - private boolean accountIsUpdated = false; - private boolean postsSetupDone = false; - private Set selectedFeedModels; - private long myId; - private PostsLayoutPreferences layoutPreferences = Utils.getPostsLayoutPreferences(Constants.PREF_PROFILE_POSTS_LAYOUT); - private LayoutProfileDetailsBinding profileDetailsBinding; - private AccountRepository accountRepository; - private FavoriteRepository favoriteRepository; - private AppStateViewModel appStateViewModel; - private boolean disableDm = false; - private ProfileFragmentViewModel viewModel; - private String csrfToken; - private String deviceUuid; - private MutableLiveData backStackSavedStateResultLiveData; - - private final ServiceCallback changeCb = new ServiceCallback() { - @Override - public void onSuccess(final FriendshipChangeResponse result) { - if (result.getFriendshipStatus() != null) { - profileModel.setFriendshipStatus(result.getFriendshipStatus()); - setProfileDetails(); - return; - } - fetchProfileDetails(); - } - - @Override - public void onFailure(final Throwable t) { - Log.e(TAG, "Error editing relationship", t); - } - }; - private final Runnable usernameSettingRunnable = () -> { - final ActionBar actionBar = fragmentActivity.getSupportActionBar(); - if (actionBar != null && !TextUtils.isEmpty(username)) { - final String finalUsername = username.startsWith("@") ? username.substring(1) - : username; - actionBar.setTitle(finalUsername); - actionBar.setSubtitle(null); - } - }; - private final OnBackPressedCallback onBackPressedCallback = new OnBackPressedCallback(false) { - @Override - public void handleOnBackPressed() { - binding.postsRecyclerView.endSelection(); - } - }; - private final PrimaryActionModeCallback multiSelectAction = new PrimaryActionModeCallback( - R.menu.multi_select_download_menu, - new CallbacksHelper() { - @Override - public void onDestroy(final ActionMode mode) { - binding.postsRecyclerView.endSelection(); - } - - @Override - public boolean onActionItemClicked(final ActionMode mode, final MenuItem item) { - if (item.getItemId() == R.id.action_download) { - if (ProfileFragment.this.selectedFeedModels == null) return false; - final Context context = getContext(); - if (context == null) return false; - DownloadUtils.download(context, ImmutableList.copyOf(ProfileFragment.this.selectedFeedModels)); - binding.postsRecyclerView.endSelection(); - return true; - } - return false; - } - }); - private final FeedAdapterV2.FeedItemCallback feedItemCallback = new FeedAdapterV2.FeedItemCallback() { - @Override - public void onPostClick(final Media feedModel, final View profilePicView, final View mainPostImage) { - openPostDialog(feedModel, profilePicView, mainPostImage, -1); - } - - @Override - public void onSliderClick(final Media feedModel, final int position) { - openPostDialog(feedModel, null, null, position); - } - - @Override - public void onCommentsClick(final Media feedModel) { - final NavDirections commentsAction = ProfileFragmentDirections.actionGlobalCommentsViewerFragment( - feedModel.getCode(), - feedModel.getPk(), - feedModel.getUser().getPk() - ); - NavHostFragment.findNavController(ProfileFragment.this).navigate(commentsAction); - } - - @Override - public void onDownloadClick(final Media feedModel, final int childPosition) { - final Context context = getContext(); - if (context == null) return; - DownloadUtils.showDownloadDialog(context, feedModel, childPosition); - } - - @Override - public void onHashtagClick(final String hashtag) { - final NavDirections action = FeedFragmentDirections.actionGlobalHashTagFragment(hashtag); - NavHostFragment.findNavController(ProfileFragment.this).navigate(action); - } - - @Override - public void onLocationClick(final Media feedModel) { - final NavDirections action = FeedFragmentDirections.actionGlobalLocationFragment(feedModel.getLocation().getPk()); - NavHostFragment.findNavController(ProfileFragment.this).navigate(action); - } - - @Override - public void onMentionClick(final String mention) { - navigateToProfile(mention.trim()); - } - - @Override - public void onNameClick(final Media feedModel, final View profilePicView) { - navigateToProfile("@" + feedModel.getUser().getUsername()); - } - - @Override - public void onProfilePicClick(final Media feedModel, final View profilePicView) { - navigateToProfile("@" + feedModel.getUser().getUsername()); - } - - @Override - public void onURLClick(final String url) { - Utils.openURL(getContext(), url); - } - - @Override - public void onEmailClick(final String emailId) { - Utils.openEmailAddress(getContext(), emailId); - } - - private void openPostDialog(final Media feedModel, - final View profilePicView, - final View mainPostImage, - final int position) { - final NavController navController = NavHostFragment.findNavController(ProfileFragment.this); - final Bundle bundle = new Bundle(); - bundle.putSerializable(PostViewV2Fragment.ARG_MEDIA, feedModel); - bundle.putInt(PostViewV2Fragment.ARG_SLIDER_POSITION, position); - try { - navController.navigate(R.id.action_global_post_view, bundle); - } catch (Exception e) { - Log.e(TAG, "openPostDialog: ", e); - } - } - }; - private final FeedAdapterV2.SelectionModeCallback selectionModeCallback = new FeedAdapterV2.SelectionModeCallback() { - - @Override - public void onSelectionStart() { - if (!onBackPressedCallback.isEnabled()) { - final OnBackPressedDispatcher onBackPressedDispatcher = fragmentActivity.getOnBackPressedDispatcher(); - onBackPressedCallback.setEnabled(true); - onBackPressedDispatcher.addCallback(getViewLifecycleOwner(), onBackPressedCallback); - } - if (actionMode == null) { - actionMode = fragmentActivity.startActionMode(multiSelectAction); - } - } - - @Override - public void onSelectionChange(final Set selectedFeedModels) { - final String title = getString(R.string.number_selected, selectedFeedModels.size()); - if (actionMode != null) { - actionMode.setTitle(title); - } - ProfileFragment.this.selectedFeedModels = selectedFeedModels; - } - - @Override - public void onSelectionEnd() { - if (onBackPressedCallback.isEnabled()) { - onBackPressedCallback.setEnabled(false); - onBackPressedCallback.remove(); - } - if (actionMode != null) { - actionMode.finish(); - actionMode = null; - } - } - }; - private final Observer backStackSavedStateObserver = result -> { - if (result == null) return; - if ((result instanceof RankedRecipient)) { - // Log.d(TAG, "result: " + result); - final Context context = getContext(); - if (context != null) { - Toast.makeText(context, R.string.sending, Toast.LENGTH_SHORT).show(); - } - viewModel.shareDm((RankedRecipient) result); - } else if ((result instanceof Set)) { - try { - // Log.d(TAG, "result: " + result); - final Context context = getContext(); - if (context != null) { - Toast.makeText(context, R.string.sending, Toast.LENGTH_SHORT).show(); - } - //noinspection unchecked - viewModel.shareDm((Set) result); - } catch (Exception e) { - Log.e(TAG, "share: ", e); - } - } - // clear result - backStackSavedStateResultLiveData.postValue(null); - }; - - @Override - public void onCreate(@Nullable final Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - cookie = Utils.settingsHelper.getString(Constants.COOKIE); - isLoggedIn = !TextUtils.isEmpty(cookie) && CookieUtils.getUserIdFromCookie(cookie) > 0; - myId = CookieUtils.getUserIdFromCookie(cookie); - deviceUuid = Utils.settingsHelper.getString(Constants.DEVICE_UUID); - csrfToken = CookieUtils.getCsrfTokenFromCookie(cookie); - fragmentActivity = (MainActivity) requireActivity(); - friendshipRepository = isLoggedIn ? FriendshipRepository.Companion.getInstance() : null; - directMessagesService = isLoggedIn ? DirectMessagesService.INSTANCE : null; - storiesRepository = isLoggedIn ? StoriesRepository.Companion.getInstance() : null; - mediaRepository = isLoggedIn ? MediaRepository.Companion.getInstance() : null; - userRepository = isLoggedIn ? UserRepository.Companion.getInstance() : null; - graphQLRepository = isLoggedIn ? null : GraphQLRepository.Companion.getInstance(); - final Context context = getContext(); - if (context == null) return; - accountRepository = AccountRepository.Companion.getInstance(context); - favoriteRepository = FavoriteRepository.Companion.getInstance(context); - appStateViewModel = new ViewModelProvider(fragmentActivity).get(AppStateViewModel.class); - viewModel = new ViewModelProvider(this, new ProfileFragmentViewModelFactory( - UserRepository.Companion.getInstance(), - FriendshipRepository.Companion.getInstance(), - StoriesRepository.Companion.getInstance(), - MediaRepository.Companion.getInstance(), - GraphQLRepository.Companion.getInstance(), - accountRepository, - favoriteRepository, - this, - getArguments() - )).get(ProfileFragmentViewModel.class); - setHasOptionsMenu(true); - } - - @Override - public View onCreateView(@NonNull final LayoutInflater inflater, - final ViewGroup container, - final Bundle savedInstanceState) { - if (root != null) { - if (getArguments() != null) { - final ProfileFragmentArgs fragmentArgs = ProfileFragmentArgs.fromBundle(getArguments()); - final String username = fragmentArgs.getUsername(); - if (TextUtils.isEmpty(username) && profileModel != null) { - final String profileModelUsername = profileModel.getUsername(); - final boolean isSame = ("@" + profileModelUsername).equals(this.username); - if (isSame) { - setUsernameDelayed(); - shouldRefresh = false; - return root; - } - } - if (username == null || !username.equals(this.username)) { - shouldRefresh = true; - return root; - } - } - setUsernameDelayed(); - shouldRefresh = false; - return root; - } - appStateViewModel.getCurrentUserLiveData().observe(getViewLifecycleOwner(), user -> viewModel.setCurrentUser(user)); - binding = FragmentProfileBinding.inflate(inflater, container, false); - root = binding.getRoot(); - profileDetailsBinding = binding.header; - return root; - } - - @Override - public void onViewCreated(@NonNull final View view, @Nullable final Bundle savedInstanceState) { - if (!shouldRefresh) return; - binding.swipeRefreshLayout.setOnRefreshListener(this); - init(); - shouldRefresh = false; - } - - @Override - public void onCreateOptionsMenu(@NonNull final Menu menu, @NonNull final MenuInflater inflater) { - inflater.inflate(R.menu.profile_menu, menu); - final boolean isNotMe = profileModel != null && isLoggedIn - && !Objects.equals(profileModel.getPk(), CookieUtils.getUserIdFromCookie(cookie)); - blockMenuItem = menu.findItem(R.id.block); - if (blockMenuItem != null) { - if (isNotMe) { - blockMenuItem.setVisible(true); - blockMenuItem.setTitle(profileModel.getFriendshipStatus().getBlocking() ? R.string.unblock : R.string.block); - } else { - blockMenuItem.setVisible(false); - } - } - restrictMenuItem = menu.findItem(R.id.restrict); - if (restrictMenuItem != null) { - if (isNotMe) { - restrictMenuItem.setVisible(true); - restrictMenuItem.setTitle(profileModel.getFriendshipStatus().isRestricted() ? R.string.unrestrict : R.string.restrict); - } else { - restrictMenuItem.setVisible(false); - } - } - muteStoriesMenuItem = menu.findItem(R.id.mute_stories); - if (muteStoriesMenuItem != null) { - if (isNotMe) { - muteStoriesMenuItem.setVisible(true); - muteStoriesMenuItem.setTitle(profileModel.getFriendshipStatus().isMutingReel() ? R.string.mute_stories : R.string.unmute_stories); - } else { - muteStoriesMenuItem.setVisible(false); - } - } - mutePostsMenuItem = menu.findItem(R.id.mute_posts); - if (mutePostsMenuItem != null) { - if (isNotMe) { - mutePostsMenuItem.setVisible(true); - mutePostsMenuItem.setTitle(profileModel.getFriendshipStatus().getMuting() ? R.string.mute_posts : R.string.unmute_posts); - } else { - mutePostsMenuItem.setVisible(false); - } - } - chainingMenuItem = menu.findItem(R.id.chaining); - if (chainingMenuItem != null) { - chainingMenuItem.setVisible(isNotMe && profileModel.getHasChaining()); - } - removeFollowerMenuItem = menu.findItem(R.id.remove_follower); - if (removeFollowerMenuItem != null) { - removeFollowerMenuItem.setVisible(isNotMe && profileModel.getFriendshipStatus().getFollowedBy()); - } - shareLinkMenuItem = menu.findItem(R.id.share_link); - if (shareLinkMenuItem != null) { - shareLinkMenuItem.setVisible(profileModel != null && !TextUtils.isEmpty(profileModel.getUsername())); - } - shareDmMenuItem = menu.findItem(R.id.share_dm); - if (shareDmMenuItem != null) { - shareDmMenuItem.setVisible(profileModel != null && profileModel.getPk() != 0L); - } - } - - @Override - public boolean onOptionsItemSelected(@NonNull final MenuItem item) { - int itemId = item.getItemId(); - if (itemId == R.id.layout) { - showPostsLayoutPreferences(); - return true; - } else if (itemId == R.id.restrict) { - if (!isLoggedIn) return false; - final String action = profileModel.getFriendshipStatus().isRestricted() ? "Unrestrict" : "Restrict"; - friendshipRepository.toggleRestrict( - csrfToken, - deviceUuid, - profileModel.getPk(), - !profileModel.getFriendshipStatus().isRestricted(), - CoroutineUtilsKt.getContinuation((response, throwable) -> AppExecutors.INSTANCE.getMainThread().execute(() -> { - if (throwable != null) { - Log.e(TAG, "Error while performing " + action, throwable); - return; - } - // Log.d(TAG, action + " success: " + response); - fetchProfileDetails(); - }), Dispatchers.getIO()) - ); - return true; - } else if (itemId == R.id.block) { - if (!isLoggedIn) return false; - // changeCb - friendshipRepository.changeBlock( - csrfToken, - myId, - deviceUuid, - profileModel.getFriendshipStatus().getBlocking(), - profileModel.getPk(), - CoroutineUtilsKt.getContinuation((response, throwable) -> AppExecutors.INSTANCE.getMainThread().execute(() -> { - if (throwable != null) { - changeCb.onFailure(throwable); - return; - } - changeCb.onSuccess(response); - }), Dispatchers.getIO()) - ); - return true; - } else if (itemId == R.id.chaining) { - if (!isLoggedIn) return false; - final Bundle bundle = new Bundle(); - bundle.putString("type", "chaining"); - bundle.putLong("targetId", profileModel.getPk()); - NavHostFragment.findNavController(this).navigate(R.id.action_global_notificationsViewerFragment, bundle); - return true; - } else if (itemId == R.id.mute_stories) { - if (!isLoggedIn) return false; - final String action = profileModel.getFriendshipStatus().isMutingReel() ? "Unmute stories" : "Mute stories"; - friendshipRepository.changeMute( - csrfToken, - myId, - deviceUuid, - profileModel.getFriendshipStatus().isMutingReel(), - profileModel.getPk(), - true, - CoroutineUtilsKt.getContinuation((response, throwable) -> AppExecutors.INSTANCE.getMainThread().execute(() -> { - if (throwable != null) { - changeCb.onFailure(throwable); - return; - } - changeCb.onSuccess(response); - }), Dispatchers.getIO()) - ); - return true; - } else if (itemId == R.id.mute_posts) { - if (!isLoggedIn) return false; - final String action = profileModel.getFriendshipStatus().getMuting() ? "Unmute stories" : "Mute stories"; - friendshipRepository.changeMute( - csrfToken, - myId, - deviceUuid, - profileModel.getFriendshipStatus().getMuting(), - profileModel.getPk(), - false, - CoroutineUtilsKt.getContinuation((response, throwable) -> AppExecutors.INSTANCE.getMainThread().execute(() -> { - if (throwable != null) { - changeCb.onFailure(throwable); - return; - } - changeCb.onSuccess(response); - }), Dispatchers.getIO()) - ); - return true; - } else if (itemId == R.id.remove_follower) { - if (!isLoggedIn) return false; - friendshipRepository.removeFollower( - csrfToken, - myId, - deviceUuid, - profileModel.getPk(), - CoroutineUtilsKt.getContinuation((response, throwable) -> AppExecutors.INSTANCE.getMainThread().execute(() -> { - if (throwable != null) { - changeCb.onFailure(throwable); - return; - } - changeCb.onSuccess(response); - }), Dispatchers.getIO()) - ); - return true; - } else if (itemId == R.id.share_link) { - final Intent sharingIntent = new Intent(Intent.ACTION_SEND); - sharingIntent.setType("text/plain"); - sharingIntent.putExtra(Intent.EXTRA_TEXT, "https://instagram.com/" + profileModel.getUsername()); - startActivity(Intent.createChooser(sharingIntent, null)); - return true; - } else if (itemId == R.id.share_dm) { - final UserSearchNavGraphDirections.ActionGlobalUserSearch actionGlobalUserSearch = UserSearchFragmentDirections - .actionGlobalUserSearch() - .setTitle(getString(R.string.share)) - .setActionLabel(getString(R.string.send)) - .setShowGroups(true) - .setMultiple(true) - .setSearchMode(UserSearchFragment.SearchMode.RAVEN); - final NavController navController = NavHostFragment.findNavController(ProfileFragment.this); - try { - navController.navigate(actionGlobalUserSearch); - } catch (Exception e) { - Log.e(TAG, "setupShare: ", e); - } - return true; - } - return super.onOptionsItemSelected(item); - } - - @Override - public void onRefresh() { - profileDetailsBinding.countsDivider.getRoot().setVisibility(View.GONE); - profileDetailsBinding.mainProfileImage.setVisibility(View.INVISIBLE); - fetchProfileDetails(); - } - - @Override - public void onResume() { - super.onResume(); - final NavController navController = NavHostFragment.findNavController(this); - final NavBackStackEntry backStackEntry = navController.getCurrentBackStackEntry(); - if (backStackEntry != null) { - backStackSavedStateResultLiveData = backStackEntry.getSavedStateHandle().getLiveData("result"); - backStackSavedStateResultLiveData.observe(getViewLifecycleOwner(), backStackSavedStateObserver); - } - } - - @Override - public void onDestroy() { - super.onDestroy(); - if (usernameSettingHandler != null) { - usernameSettingHandler.removeCallbacks(usernameSettingRunnable); - } - if (highlightsViewModel != null) { - highlightsViewModel.getList().postValue(Collections.emptyList()); - } - } - - private void init() { - disableDm = !Utils.isNavRootInCurrentTabs("direct_messages_nav_graph"); - if (getArguments() != null) { - final ProfileFragmentArgs fragmentArgs = ProfileFragmentArgs.fromBundle(getArguments()); - username = fragmentArgs.getUsername(); - if (!TextUtils.isEmpty(username) && username.startsWith("@")) { - username = username.substring(1); - } - setUsernameDelayed(); - } - if (TextUtils.isEmpty(username) && !isLoggedIn) { - binding.header.getRoot().setVisibility(View.GONE); - binding.swipeRefreshLayout.setEnabled(false); - binding.privatePage1.setImageResource(R.drawable.ic_outline_info_24); - binding.privatePage2.setText(R.string.no_acc); - binding.privatePage.setVisibility(View.VISIBLE); - return; - } - binding.swipeRefreshLayout.setEnabled(true); - setupHighlights(); - setupCommonListeners(); - fetchProfileDetails(); - } - - private void fetchProfileDetails() { - accountIsUpdated = false; - String usernameTemp = username.trim(); - if (usernameTemp.startsWith("@")) { - usernameTemp = usernameTemp.substring(1); - } - if (TextUtils.isEmpty(usernameTemp)) { - appStateViewModel.getCurrentUserLiveData().observe(getViewLifecycleOwner(), userResource -> { - if (userResource == null) return; - final User user = userResource.data; - if (user == null) return; - username = user.getUsername(); - if (TextUtils.isEmpty(username)) { - appStateViewModel.fetchProfileDetails(); - return; - } - profileModel = user; - username = profileModel.getUsername(); - setUsernameDelayed(); - setProfileDetails(); - }); - return; - } - if (isLoggedIn) { - userRepository.getUsernameInfo( - usernameTemp, - CoroutineUtilsKt.getContinuation((user, throwable) -> AppExecutors.INSTANCE.getMainThread().execute(() -> { - if (throwable != null) { - Log.e(TAG, "Error fetching profile", throwable); - final Context context = getContext(); - if (context == null) return; - Toast.makeText(context, throwable.getMessage(), Toast.LENGTH_SHORT).show(); - return; - } - userRepository.getUserFriendship( - user.getPk(), - CoroutineUtilsKt.getContinuation( - (friendshipStatus, throwable1) -> AppExecutors.INSTANCE.getMainThread().execute(() -> { - if (throwable1 != null) { - Log.e(TAG, "Error fetching profile relationship", throwable1); - final Context context = getContext(); - if (context == null) return; - Toast.makeText(context, throwable1.getMessage(), - Toast.LENGTH_SHORT).show(); - return; - } - user.setFriendshipStatus(friendshipStatus); - profileModel = user; - setProfileDetails(); - }), Dispatchers.getIO() - ) - ); - }), Dispatchers.getIO()) - ); - return; - } - graphQLRepository.fetchUser( - usernameTemp, - CoroutineUtilsKt.getContinuation((user, throwable) -> AppExecutors.INSTANCE.getMainThread().execute(() -> { - if (throwable != null) { - Log.e(TAG, "Error fetching profile", throwable); - final Context context = getContext(); - if (context == null) return; - Toast.makeText(context, throwable.getMessage(), Toast.LENGTH_SHORT).show(); - } - profileModel = user; - setProfileDetails(); - })) - ); - } - - private void setProfileDetails() { - final Context context = getContext(); - if (context == null) return; - if (profileModel == null) { - binding.swipeRefreshLayout.setRefreshing(false); - Toast.makeText(context, R.string.error_loading_profile, Toast.LENGTH_SHORT).show(); - return; - } - final long profileId = profileModel.getPk(); - if (!isReallyPrivate()) { - if (!postsSetupDone) { - setupPosts(); - } else { - binding.postsRecyclerView.refresh(); - } - if (isLoggedIn) { - fetchStoryAndHighlights(profileId); - } - } - - profileDetailsBinding.isVerified.setVisibility(profileModel.isVerified() ? View.VISIBLE : View.GONE); - profileDetailsBinding.isPrivate.setVisibility(profileModel.isPrivate() ? View.VISIBLE : View.GONE); - - setupButtons(profileId); - final FavoriteRepository favoriteRepository = FavoriteRepository.Companion.getInstance(getContext()); - favoriteRepository.getFavorite( - profileModel.getUsername(), - FavoriteType.USER, - CoroutineUtilsKt.getContinuation((favorite, throwable) -> AppExecutors.INSTANCE.getMainThread().execute(() -> { - if (throwable != null || favorite == null) { - profileDetailsBinding.favChip.setChipIconResource(R.drawable.ic_outline_star_plus_24); - profileDetailsBinding.favChip.setText(R.string.add_to_favorites); - if (throwable != null) { - Log.e(TAG, "setProfileDetails: ", throwable); - } - return; - } - profileDetailsBinding.favChip.setChipIconResource(R.drawable.ic_star_check_24); - profileDetailsBinding.favChip.setText(R.string.favorite_short); - favoriteRepository.insertOrUpdateFavorite( - new Favorite( - favorite.getId(), - profileModel.getUsername(), - FavoriteType.USER, - profileModel.getFullName(), - profileModel.getProfilePicUrl(), - favorite.getDateAdded() - ), - CoroutineUtilsKt.getContinuation((unit, throwable1) -> AppExecutors.INSTANCE.getMainThread().execute(() -> { - if (throwable1 != null) { - Log.e(TAG, "onSuccess: ", throwable1); - } - }), Dispatchers.getIO()) - ); - })) - ); - profileDetailsBinding.favChip.setOnClickListener(v -> favoriteRepository.getFavorite( - profileModel.getUsername(), - FavoriteType.USER, - CoroutineUtilsKt.getContinuation((favorite, throwable) -> AppExecutors.INSTANCE.getMainThread().execute(() -> { - if (throwable != null) { - Log.e(TAG, "setProfileDetails: ", throwable); - return; - } - if (favorite == null) { - favoriteRepository.insertOrUpdateFavorite( - new Favorite( - 0, - profileModel.getUsername(), - FavoriteType.USER, - profileModel.getFullName(), - profileModel.getProfilePicUrl(), - LocalDateTime.now() - ), - CoroutineUtilsKt.getContinuation((unit, throwable1) -> AppExecutors.INSTANCE.getMainThread().execute(() -> { - if (throwable1 != null) { - Log.e(TAG, "onDataNotAvailable: ", throwable1); - return; - } - profileDetailsBinding.favChip.setText(R.string.favorite_short); - profileDetailsBinding.favChip.setChipIconResource(R.drawable.ic_star_check_24); - showSnackbar(getString(R.string.added_to_favs)); - }), Dispatchers.getIO()) - ); - return; - } - favoriteRepository.deleteFavorite( - profileModel.getUsername(), - FavoriteType.USER, - CoroutineUtilsKt.getContinuation((unit, throwable1) -> AppExecutors.INSTANCE.getMainThread().execute(() -> { - if (throwable1 != null) { - Log.e(TAG, "onSuccess: ", throwable1); - return; - } - profileDetailsBinding.favChip.setText(R.string.add_to_favorites); - profileDetailsBinding.favChip.setChipIconResource(R.drawable.ic_outline_star_plus_24); - showSnackbar(getString(R.string.removed_from_favs)); - }), Dispatchers.getIO()) - ); - }), Dispatchers.getIO()) - )); - profileDetailsBinding.mainProfileImage.setImageURI(profileModel.getProfilePicUrl()); - profileDetailsBinding.mainProfileImage.setVisibility(View.VISIBLE); - - profileDetailsBinding.countsDivider.getRoot().setVisibility(View.VISIBLE); - - final long followersCount = profileModel.getFollowerCount(); - final long followingCount = profileModel.getFollowingCount(); - - final String postCount = String.valueOf(profileModel.getMediaCount()); - - SpannableStringBuilder span = new SpannableStringBuilder(getResources().getQuantityString( - R.plurals.main_posts_count, - profileModel.getMediaCount() > 2000000000L ? 2000000000 : (int) profileModel.getMediaCount(), - postCount) - ); - span.setSpan(new RelativeSizeSpan(1.2f), 0, postCount.length(), 0); - span.setSpan(new StyleSpan(Typeface.BOLD), 0, postCount.length(), 0); - profileDetailsBinding.mainPostCount.setText(span); - profileDetailsBinding.mainPostCount.setVisibility(View.VISIBLE); - - final String followersCountStr = String.valueOf(followersCount); - final int followersCountStrLen = followersCountStr.length(); - span = new SpannableStringBuilder(getResources().getQuantityString( - R.plurals.main_posts_followers, - followersCount > 2000000000L ? 2000000000 : (int) followersCount, - followersCountStr) - ); - span.setSpan(new RelativeSizeSpan(1.2f), 0, followersCountStrLen, 0); - span.setSpan(new StyleSpan(Typeface.BOLD), 0, followersCountStrLen, 0); - profileDetailsBinding.mainFollowers.setText(span); - profileDetailsBinding.mainFollowers.setVisibility(View.VISIBLE); - - final String followingCountStr = String.valueOf(followingCount); - final int followingCountStrLen = followingCountStr.length(); - span = new SpannableStringBuilder(getString(R.string.main_posts_following, followingCountStr)); - span.setSpan(new RelativeSizeSpan(1.2f), 0, followingCountStrLen, 0); - span.setSpan(new StyleSpan(Typeface.BOLD), 0, followingCountStrLen, 0); - profileDetailsBinding.mainFollowing.setText(span); - profileDetailsBinding.mainFollowing.setVisibility(View.VISIBLE); - - profileDetailsBinding.mainFullName.setText(TextUtils.isEmpty(profileModel.getFullName()) ? profileModel.getUsername() - : profileModel.getFullName()); - - final String biography = profileModel.getBiography(); - if (TextUtils.isEmpty(biography)) { - profileDetailsBinding.mainBiography.setVisibility(View.GONE); - } else { - profileDetailsBinding.mainBiography.setVisibility(View.VISIBLE); - profileDetailsBinding.mainBiography.setText(biography); - profileDetailsBinding.mainBiography.addOnHashtagListener(autoLinkItem -> { - final NavController navController = NavHostFragment.findNavController(this); - final Bundle bundle = new Bundle(); - final String originalText = autoLinkItem.getOriginalText().trim(); - bundle.putString(ARG_HASHTAG, originalText); - navController.navigate(R.id.action_global_hashTagFragment, bundle); - }); - profileDetailsBinding.mainBiography.addOnMentionClickListener(autoLinkItem -> { - final String originalText = autoLinkItem.getOriginalText().trim(); - navigateToProfile(originalText); - }); - profileDetailsBinding.mainBiography.addOnEmailClickListener(autoLinkItem -> Utils.openEmailAddress( - getContext(), autoLinkItem.getOriginalText().trim())); - profileDetailsBinding.mainBiography - .addOnURLClickListener(autoLinkItem -> Utils.openURL(getContext(), autoLinkItem.getOriginalText().trim())); - profileDetailsBinding.mainBiography.setOnLongClickListener(v -> { - String[] commentDialogList; - if (!TextUtils.isEmpty(cookie)) { - commentDialogList = new String[]{ - getResources().getString(R.string.bio_copy), - getResources().getString(R.string.bio_translate) - }; - } else { - commentDialogList = new String[]{ - getResources().getString(R.string.bio_copy) - }; - } - new AlertDialog.Builder(context) - .setItems(commentDialogList, (d, w) -> { - switch (w) { - case 0: - Utils.copyText(context, biography); - break; - case 1: - mediaRepository.translate(String.valueOf(profileModel.getPk()), "3", CoroutineUtilsKt.getContinuation( - (result, throwable) -> AppExecutors.INSTANCE.getMainThread().execute(() -> { - if (throwable != null) { - Log.e(TAG, "Error translating bio", throwable); - Toast.makeText(context, throwable.getMessage(), Toast.LENGTH_SHORT).show(); - return; - } - if (TextUtils.isEmpty(result)) { - Toast.makeText(context, R.string.downloader_unknown_error, Toast.LENGTH_SHORT) - .show(); - return; - } - new AlertDialog.Builder(context) - .setTitle(profileModel.getUsername()) - .setMessage(result) - .setPositiveButton(R.string.ok, null) - .show(); - }), - Dispatchers.getIO() - )); - break; - } - }) - .setNegativeButton(R.string.cancel, null) - .show(); - return true; - }); - } - - String profileContext = profileModel.getProfileContext(); - if (TextUtils.isEmpty(profileContext)) { - profileDetailsBinding.profileContext.setVisibility(View.GONE); - } else { - profileDetailsBinding.profileContext.setVisibility(View.VISIBLE); - final List userProfileContextLinks = profileModel.getProfileContextLinksWithUserIds(); - for (int i = 0; i < userProfileContextLinks.size(); i++) { - final UserProfileContextLink link = userProfileContextLinks.get(i); - if (link.getUsername() != null) - profileContext = profileContext.substring(0, link.getStart() + i) - + "@" + profileContext.substring(link.getStart() + i); - } - profileDetailsBinding.profileContext.setText(profileContext); - profileDetailsBinding.profileContext.addOnMentionClickListener(autoLinkItem -> { - final String originalText = autoLinkItem.getOriginalText().trim(); - navigateToProfile(originalText); - }); - } - - final String url = profileModel.getExternalUrl(); - if (TextUtils.isEmpty(url)) { - profileDetailsBinding.mainUrl.setVisibility(View.GONE); - } else { - profileDetailsBinding.mainUrl.setVisibility(View.VISIBLE); - profileDetailsBinding.mainUrl.setText(url); - profileDetailsBinding.mainUrl.addOnURLClickListener(autoLinkItem -> Utils.openURL(getContext(), autoLinkItem.getOriginalText().trim())); - profileDetailsBinding.mainUrl.setOnLongClickListener(v -> { - Utils.copyText(context, url); - return true; - }); - } - final MotionScene.Transition transition = root.getTransition(R.id.transition); - if (!isReallyPrivate()) { - if (isLoggedIn) { - profileDetailsBinding.mainFollowing.setClickable(true); - profileDetailsBinding.mainFollowers.setClickable(true); - final View.OnClickListener followClickListener = v -> { - final NavDirections action = ProfileFragmentDirections.actionProfileFragmentToFollowViewerFragment( - profileId, - v == profileDetailsBinding.mainFollowers, - profileModel.getUsername()); - NavHostFragment.findNavController(this).navigate(action); - }; - profileDetailsBinding.mainFollowers.setOnClickListener(followersCount > 0 ? followClickListener : null); - profileDetailsBinding.mainFollowing.setOnClickListener(followingCount > 0 ? followClickListener : null); - } - binding.postsRecyclerView.setVisibility(View.VISIBLE); - } else { - profileDetailsBinding.mainFollowers.setClickable(false); - profileDetailsBinding.mainFollowing.setClickable(false); - binding.privatePage1.setImageResource(R.drawable.lock); - binding.privatePage2.setText(R.string.priv_acc); - binding.privatePage.setVisibility(View.VISIBLE); - binding.privatePage1.setVisibility(View.VISIBLE); - binding.privatePage2.setVisibility(View.VISIBLE); - binding.postsRecyclerView.setVisibility(View.GONE); - binding.swipeRefreshLayout.setRefreshing(false); - if (transition != null) { - transition.setEnable(false); - } - } - if (profileModel.getMediaCount() == 0 && transition != null) { - transition.setEnable(false); - } - } - - private void setupButtons(final long profileId) { - profileDetailsBinding.btnTagged.setVisibility(isReallyPrivate() ? View.GONE : View.VISIBLE); - profileDetailsBinding.favChip.setVisibility(View.VISIBLE); - if (isLoggedIn) { - if (Objects.equals(profileId, myId)) { - profileDetailsBinding.btnTagged.setVisibility(View.VISIBLE); - profileDetailsBinding.btnSaved.setVisibility(View.VISIBLE); - profileDetailsBinding.btnLiked.setVisibility(View.VISIBLE); - profileDetailsBinding.btnDM.setVisibility(View.GONE); - profileDetailsBinding.favChip.setVisibility(View.GONE); - profileDetailsBinding.btnSaved.setText(R.string.saved); - if (!accountIsUpdated) updateAccountInfo(); - return; - } - profileDetailsBinding.btnSaved.setVisibility(View.GONE); - profileDetailsBinding.btnLiked.setVisibility(View.GONE); - profileDetailsBinding.btnDM.setVisibility(disableDm ? View.GONE : View.VISIBLE); - profileDetailsBinding.btnFollow.setVisibility(View.VISIBLE); - final Context context = getContext(); - if (context == null) return; - if (profileModel.getFriendshipStatus().getFollowing() || profileModel.getFriendshipStatus().getFollowedBy()) { - profileDetailsBinding.mainStatus.setVisibility(View.VISIBLE); - if (!profileModel.getFriendshipStatus().getFollowing()) { - profileDetailsBinding.mainStatus.setChipBackgroundColor(AppCompatResources.getColorStateList(context, R.color.blue_800)); - profileDetailsBinding.mainStatus.setText(R.string.status_follower); - } else if (!profileModel.getFriendshipStatus().getFollowedBy()) { - profileDetailsBinding.mainStatus.setChipBackgroundColor(AppCompatResources.getColorStateList(context, R.color.deep_orange_800)); - profileDetailsBinding.mainStatus.setText(R.string.status_following); - } else { - profileDetailsBinding.mainStatus.setChipBackgroundColor(AppCompatResources.getColorStateList(context, R.color.green_800)); - profileDetailsBinding.mainStatus.setText(R.string.status_mutual); - } - } else profileDetailsBinding.mainStatus.setVisibility(View.GONE); - if (profileModel.getFriendshipStatus().getFollowing()) { - profileDetailsBinding.btnFollow.setText(R.string.unfollow); - profileDetailsBinding.btnFollow.setChipIconResource(R.drawable.ic_outline_person_add_disabled_24); - } else if (profileModel.getFriendshipStatus().getOutgoingRequest()) { - profileDetailsBinding.btnFollow.setText(R.string.cancel); - profileDetailsBinding.btnFollow.setChipIconResource(R.drawable.ic_outline_person_add_disabled_24); - } else { - profileDetailsBinding.btnFollow.setText(R.string.follow); - profileDetailsBinding.btnFollow.setChipIconResource(R.drawable.ic_outline_person_add_24); - } - if (restrictMenuItem != null) { - restrictMenuItem.setVisible(true); - restrictMenuItem.setTitle(profileModel.getFriendshipStatus().isRestricted() ? R.string.unrestrict : R.string.restrict); - } - if (blockMenuItem != null) { - blockMenuItem.setVisible(true); - blockMenuItem.setTitle(profileModel.getFriendshipStatus().getBlocking() ? R.string.unblock : R.string.block); - } - if (muteStoriesMenuItem != null) { - muteStoriesMenuItem.setVisible(true); - muteStoriesMenuItem.setTitle(profileModel.getFriendshipStatus().isMutingReel() ? R.string.unmute_stories : R.string.mute_stories); - } - if (mutePostsMenuItem != null) { - mutePostsMenuItem.setVisible(true); - mutePostsMenuItem.setTitle(profileModel.getFriendshipStatus().getMuting() ? R.string.unmute_posts : R.string.mute_posts); - } - if (chainingMenuItem != null) { - chainingMenuItem.setVisible(profileModel.getHasChaining()); - } - if (removeFollowerMenuItem != null) { - removeFollowerMenuItem.setVisible(profileModel.getFriendshipStatus().getFollowedBy()); - } - if (shareLinkMenuItem != null) { - shareLinkMenuItem.setVisible(!TextUtils.isEmpty(profileModel.getUsername())); - } - if (shareDmMenuItem != null) { - shareDmMenuItem.setVisible(profileModel.getPk() != 0L); - } - } - } - - private void updateAccountInfo() { - if (profileModel == null || TextUtils.isEmpty(profileModel.getUsername())) return; - accountRepository.insertOrUpdateAccount( - profileModel.getPk(), - profileModel.getUsername(), - cookie, - profileModel.getFullName(), - profileModel.getProfilePicUrl(), - CoroutineUtilsKt.getContinuation((account, throwable) -> AppExecutors.INSTANCE.getMainThread().execute(() -> { - if (throwable != null) { - Log.e(TAG, "updateAccountInfo: ", throwable); - return; - } - accountIsUpdated = true; - }), Dispatchers.getIO()) - ); - } - - private void fetchStoryAndHighlights(final long profileId) { - storiesRepository.getUserStory( - StoryViewerOptions.forUser(profileId, profileModel.getFullName()), - CoroutineUtilsKt.getContinuation((storyModels, throwable) -> AppExecutors.INSTANCE.getMainThread().execute(() -> { - if (throwable != null) { - Log.e(TAG, "Error", throwable); - return; - } - if (storyModels != null && !storyModels.isEmpty()) { - profileDetailsBinding.mainProfileImage.setStoriesBorder(1); - hasStories = true; - } - }), Dispatchers.getIO()) - ); - storiesRepository.fetchHighlights( - profileId, - CoroutineUtilsKt.getContinuation((highlightModels, throwable) -> AppExecutors.INSTANCE.getMainThread().execute(() -> { - if (throwable != null) { - profileDetailsBinding.highlightsList.setVisibility(View.GONE); - Log.e(TAG, "Error", throwable); - return; - } - if (highlightModels != null) { - profileDetailsBinding.highlightsList.setVisibility(View.VISIBLE); - //noinspection unchecked - highlightsViewModel.getList().postValue((List) highlightModels); - } else { - profileDetailsBinding.highlightsList.setVisibility(View.GONE); - } - }), Dispatchers.getIO()) - ); - } - - private void setupCommonListeners() { - final Context context = getContext(); - if (context == null) return; - profileDetailsBinding.btnFollow.setOnClickListener(v -> { - if (profileModel.getFriendshipStatus().getFollowing() && profileModel.isPrivate()) { - new AlertDialog.Builder(context) - .setTitle(R.string.priv_acc) - .setMessage(R.string.priv_acc_confirm) - .setPositiveButton(R.string.confirm, (d, w) -> friendshipRepository.unfollow( - csrfToken, - myId, - deviceUuid, - profileModel.getPk(), - CoroutineUtilsKt.getContinuation((response, throwable) -> AppExecutors.INSTANCE.getMainThread().execute(() -> { - if (throwable != null) { - changeCb.onFailure(throwable); - return; - } - changeCb.onSuccess(response); - }), Dispatchers.getIO()) - )) - .setNegativeButton(R.string.cancel, null) - .show(); - } else if (profileModel.getFriendshipStatus().getFollowing() || profileModel.getFriendshipStatus().getOutgoingRequest()) { - friendshipRepository.unfollow( - csrfToken, - myId, - deviceUuid, - profileModel.getPk(), - CoroutineUtilsKt.getContinuation((response, throwable) -> AppExecutors.INSTANCE.getMainThread().execute(() -> { - if (throwable != null) { - changeCb.onFailure(throwable); - return; - } - changeCb.onSuccess(response); - }), Dispatchers.getIO()) - ); - } else { - friendshipRepository.follow( - csrfToken, - myId, - deviceUuid, - profileModel.getPk(), - CoroutineUtilsKt.getContinuation((response, throwable) -> AppExecutors.INSTANCE.getMainThread().execute(() -> { - if (throwable != null) { - changeCb.onFailure(throwable); - return; - } - changeCb.onSuccess(response); - }), Dispatchers.getIO()) - ); - } - }); - profileDetailsBinding.btnSaved.setOnClickListener(v -> { - final NavDirections action = ProfileFragmentDirections.actionGlobalSavedCollectionsFragment(false); - NavHostFragment.findNavController(this).navigate(action); - }); - profileDetailsBinding.btnLiked.setOnClickListener(v -> { - final NavDirections action = ProfileFragmentDirections.actionProfileFragmentToSavedViewerFragment(profileModel.getUsername(), - profileModel.getPk(), - PostItemType.LIKED); - NavHostFragment.findNavController(this).navigate(action); - }); - profileDetailsBinding.btnTagged.setOnClickListener(v -> { - final NavDirections action = ProfileFragmentDirections.actionProfileFragmentToSavedViewerFragment(profileModel.getUsername(), - profileModel.getPk(), - PostItemType.TAGGED); - NavHostFragment.findNavController(this).navigate(action); - }); - if (!disableDm) { - profileDetailsBinding.btnDM.setOnClickListener(v -> { - profileDetailsBinding.btnDM.setEnabled(false); - directMessagesService.createThread( - csrfToken, - myId, - deviceUuid, - Collections.singletonList(profileModel.getPk()), - null, - CoroutineUtilsKt.getContinuation((thread, throwable) -> AppExecutors.INSTANCE.getMainThread().execute(() -> { - if (throwable != null) { - Log.e(TAG, "setupCommonListeners: ", throwable); - Toast.makeText(context, R.string.downloader_unknown_error, Toast.LENGTH_SHORT).show(); - return; - } - profileDetailsBinding.btnDM.setEnabled(true); - final InboxManager inboxManager = DirectMessagesManager.INSTANCE.getInboxManager(); - if (!inboxManager.containsThread(thread.getThreadId())) { - thread.setTemp(true); - inboxManager.addThread(thread, 0); - } - fragmentActivity.navigateToThread(thread.getThreadId(), profileModel.getUsername()); - }), Dispatchers.getIO()) - ); - }); - } - profileDetailsBinding.mainProfileImage.setOnClickListener(v -> { - if (!hasStories) { - // show profile pic - showProfilePicDialog(); - return; - } - // show dialog - final String[] options = {getString(R.string.view_pfp), getString(R.string.show_stories)}; - final DialogInterface.OnClickListener profileDialogListener = (dialog, which) -> { - if (which == AlertDialog.BUTTON_NEUTRAL) { - dialog.dismiss(); - return; - } - if (which == 1) { - // show stories - final NavDirections action = ProfileFragmentDirections - .actionProfileFragmentToStoryViewerFragment(StoryViewerOptions.forUser(profileModel.getPk(), - profileModel.getFullName())); - NavHostFragment.findNavController(this).navigate(action); - return; - } - showProfilePicDialog(); - }; - new AlertDialog.Builder(context) - .setItems(options, profileDialogListener) - .setNegativeButton(R.string.cancel, null) - .show(); - }); - } - - private void showSnackbar(final String message) { - final Snackbar snackbar = Snackbar.make(root, message, BaseTransientBottomBar.LENGTH_LONG); - snackbar.setAction(R.string.ok, v -> snackbar.dismiss()) - .setAnimationMode(BaseTransientBottomBar.ANIMATION_MODE_SLIDE) - .setAnchorView(fragmentActivity.getBottomNavView()) - .show(); - } - - private void showProfilePicDialog() { - if (profileModel != null) { - final FragmentManager fragmentManager = getParentFragmentManager(); - final ProfilePicDialogFragment fragment = ProfilePicDialogFragment.getInstance(profileModel.getPk(), - username, - profileModel.getProfilePicUrl()); - final FragmentTransaction ft = fragmentManager.beginTransaction(); - ft.setTransition(FragmentTransaction.TRANSIT_FRAGMENT_OPEN) - .add(fragment, "profilePicDialog") - .commit(); - } - } - - private void setUsernameDelayed() { - if (usernameSettingHandler == null) { - usernameSettingHandler = new Handler(Looper.getMainLooper()); - } - usernameSettingHandler.postDelayed(usernameSettingRunnable, 200); - } - - private void setupPosts() { - binding.postsRecyclerView.setViewModelStoreOwner(this) - .setLifeCycleOwner(this) - .setPostFetchService(new ProfilePostFetchService(profileModel, isLoggedIn)) - .setLayoutPreferences(layoutPreferences) - .addFetchStatusChangeListener(fetching -> updateSwipeRefreshState()) - .setFeedItemCallback(feedItemCallback) - .setSelectionModeCallback(selectionModeCallback) - .init(); - binding.postsRecyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() { - @Override - public void onScrolled(@NonNull final RecyclerView recyclerView, final int dx, final int dy) { - super.onScrolled(recyclerView, dx, dy); - final boolean canScrollVertically = recyclerView.canScrollVertically(-1); - final MotionScene.Transition transition = root.getTransition(R.id.transition); - if (transition != null) { - transition.setEnable(!canScrollVertically); - } - } - }); - binding.swipeRefreshLayout.setRefreshing(true); - postsSetupDone = true; - } - - private void updateSwipeRefreshState() { - binding.swipeRefreshLayout.setRefreshing(binding.postsRecyclerView.isFetching()); - } - - private void setupHighlights() { - highlightsViewModel = new ViewModelProvider(fragmentActivity).get(HighlightsViewModel.class); - highlightsAdapter = new HighlightsAdapter((model, position) -> { - final StoryViewerOptions options = StoryViewerOptions.forHighlight(model.getTitle()); - options.setCurrentFeedStoryIndex(position); - final NavDirections action = ProfileFragmentDirections.actionProfileFragmentToStoryViewerFragment(options); - NavHostFragment.findNavController(this).navigate(action); - }); - final Context context = getContext(); - if (context == null) return; - final RecyclerView.LayoutManager layoutManager = new LinearLayoutManager(context, RecyclerView.HORIZONTAL, false); - profileDetailsBinding.highlightsList.setLayoutManager(layoutManager); - profileDetailsBinding.highlightsList.setAdapter(highlightsAdapter); - highlightsViewModel.getList().observe(getViewLifecycleOwner(), highlightModels -> highlightsAdapter.submitList(highlightModels)); - } - - private void navigateToProfile(final String username) { - final NavController navController = NavHostFragment.findNavController(this); - final Bundle bundle = new Bundle(); - bundle.putString("username", username); - navController.navigate(R.id.action_global_profileFragment, bundle); - } - - private void showPostsLayoutPreferences() { - final PostsLayoutPreferencesDialogFragment fragment = new PostsLayoutPreferencesDialogFragment( - Constants.PREF_PROFILE_POSTS_LAYOUT, - preferences -> { - layoutPreferences = preferences; - new Handler().postDelayed(() -> binding.postsRecyclerView.setLayoutPreferences(preferences), 200); - }); - fragment.show(getChildFragmentManager(), "posts_layout_preferences"); - } - - private boolean isReallyPrivate() { - if (profileModel.getPk() == myId) return false; - final FriendshipStatus friendshipStatus = profileModel.getFriendshipStatus(); - return !friendshipStatus.getFollowing() && profileModel.isPrivate(); - } -} diff --git a/app/src/main/java/awais/instagrabber/fragments/main/ProfileFragment.kt b/app/src/main/java/awais/instagrabber/fragments/main/ProfileFragment.kt new file mode 100644 index 00000000..6b87ce04 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/fragments/main/ProfileFragment.kt @@ -0,0 +1,981 @@ +package awais.instagrabber.fragments.main + +import android.content.Intent +import android.graphics.Typeface +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.text.SpannableStringBuilder +import android.text.style.RelativeSizeSpan +import android.text.style.StyleSpan +import android.util.Log +import android.view.* +import android.widget.Toast +import androidx.activity.OnBackPressedCallback +import androidx.appcompat.content.res.AppCompatResources +import androidx.constraintlayout.motion.widget.MotionLayout +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentTransaction +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.Observer +import androidx.lifecycle.ViewModelProvider +import androidx.navigation.fragment.NavHostFragment +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout.OnRefreshListener +import awais.instagrabber.R +import awais.instagrabber.activities.MainActivity +import awais.instagrabber.adapters.FeedAdapterV2 +import awais.instagrabber.adapters.HighlightsAdapter +import awais.instagrabber.asyncs.ProfilePostFetchService +import awais.instagrabber.customviews.PrimaryActionModeCallback +import awais.instagrabber.customviews.RamboTextViewV2 +import awais.instagrabber.customviews.RamboTextViewV2.* +import awais.instagrabber.databinding.FragmentProfileBinding +import awais.instagrabber.db.repositories.AccountRepository +import awais.instagrabber.db.repositories.FavoriteRepository +import awais.instagrabber.dialogs.ConfirmDialogFragment +import awais.instagrabber.dialogs.ConfirmDialogFragment.ConfirmDialogFragmentCallback +import awais.instagrabber.dialogs.MultiOptionDialogFragment +import awais.instagrabber.dialogs.MultiOptionDialogFragment.MultiOptionDialogSingleCallback +import awais.instagrabber.dialogs.MultiOptionDialogFragment.Option +import awais.instagrabber.dialogs.PostsLayoutPreferencesDialogFragment +import awais.instagrabber.dialogs.ProfilePicDialogFragment +import awais.instagrabber.fragments.HashTagFragment.ARG_HASHTAG +import awais.instagrabber.fragments.PostViewV2Fragment +import awais.instagrabber.fragments.UserSearchFragment +import awais.instagrabber.fragments.UserSearchFragmentDirections +import awais.instagrabber.managers.DirectMessagesManager +import awais.instagrabber.models.Resource +import awais.instagrabber.models.enums.PostItemType +import awais.instagrabber.repositories.requests.StoryViewerOptions +import awais.instagrabber.repositories.responses.FriendshipStatus +import awais.instagrabber.repositories.responses.Media +import awais.instagrabber.repositories.responses.User +import awais.instagrabber.repositories.responses.UserProfileContextLink +import awais.instagrabber.repositories.responses.directmessages.RankedRecipient +import awais.instagrabber.utils.* +import awais.instagrabber.utils.extensions.TAG +import awais.instagrabber.utils.extensions.isReallyPrivate +import awais.instagrabber.utils.extensions.trimAll +import awais.instagrabber.viewmodels.AppStateViewModel +import awais.instagrabber.viewmodels.ProfileFragmentViewModel +import awais.instagrabber.viewmodels.ProfileFragmentViewModel.ProfileEvent.* +import awais.instagrabber.viewmodels.ProfileFragmentViewModelFactory +import awais.instagrabber.webservices.* + +class ProfileFragment : Fragment(), OnRefreshListener, ConfirmDialogFragmentCallback, MultiOptionDialogSingleCallback { + private var backStackSavedStateResultLiveData: MutableLiveData? = null + private var shareDmMenuItem: MenuItem? = null + private var shareLinkMenuItem: MenuItem? = null + private var removeFollowerMenuItem: MenuItem? = null + private var chainingMenuItem: MenuItem? = null + private var mutePostsMenuItem: MenuItem? = null + private var muteStoriesMenuItem: MenuItem? = null + private var restrictMenuItem: MenuItem? = null + private var blockMenuItem: MenuItem? = null + private var setupPostsDone: Boolean = false + private var selectedMedia: List? = null + private var actionMode: ActionMode? = null + private var disableDm: Boolean = false + private var shouldRefresh: Boolean = true + private var highlightsAdapter: HighlightsAdapter? = null + private var layoutPreferences = Utils.getPostsLayoutPreferences(Constants.PREF_PROFILE_POSTS_LAYOUT) + + private lateinit var mainActivity: MainActivity + private lateinit var root: MotionLayout + private lateinit var binding: FragmentProfileBinding + private lateinit var appStateViewModel: AppStateViewModel + private lateinit var viewModel: ProfileFragmentViewModel + + private val confirmDialogFragmentRequestCode = 100 + private val ppOptsDialogRequestCode = 101 + private val bioDialogRequestCode = 102 + private val translationDialogRequestCode = 103 + private val feedItemCallback: FeedAdapterV2.FeedItemCallback = object : FeedAdapterV2.FeedItemCallback { + override fun onPostClick(media: Media?, profilePicView: View?, mainPostImage: View?) { + openPostDialog(media ?: return, -1) + } + + override fun onProfilePicClick(media: Media?, profilePicView: View?) { + navigateToProfile(media?.user?.username) + } + + override fun onNameClick(media: Media?, profilePicView: View?) { + navigateToProfile(media?.user?.username) + } + + override fun onLocationClick(media: Media?) { + val action = FeedFragmentDirections.actionGlobalLocationFragment(media?.location?.pk ?: return) + NavHostFragment.findNavController(this@ProfileFragment).navigate(action) + } + + override fun onMentionClick(mention: String?) { + navigateToProfile(mention?.trimAll() ?: return) + } + + override fun onHashtagClick(hashtag: String?) { + val action = FeedFragmentDirections.actionGlobalHashTagFragment(hashtag ?: return) + NavHostFragment.findNavController(this@ProfileFragment).navigate(action) + } + + override fun onCommentsClick(media: Media?) { + val commentsAction = ProfileFragmentDirections.actionGlobalCommentsViewerFragment( + media?.code ?: return, + media.pk ?: return, + media.user?.pk ?: return + ) + NavHostFragment.findNavController(this@ProfileFragment).navigate(commentsAction) + } + + override fun onDownloadClick(media: Media?, childPosition: Int) { + DownloadUtils.showDownloadDialog(context ?: return, media ?: return, childPosition) + } + + override fun onEmailClick(emailId: String?) { + Utils.openEmailAddress(context ?: return, emailId ?: return) + } + + override fun onURLClick(url: String?) { + Utils.openURL(context ?: return, url ?: return) + } + + override fun onSliderClick(media: Media?, position: Int) { + openPostDialog(media ?: return, position) + } + } + private val onBackPressedCallback = object : OnBackPressedCallback(false) { + override fun handleOnBackPressed() { + binding.postsRecyclerView.endSelection() + } + } + private val multiSelectAction = PrimaryActionModeCallback( + R.menu.multi_select_download_menu, + object : PrimaryActionModeCallback.CallbacksHelper() { + override fun onDestroy(mode: ActionMode?) { + binding.postsRecyclerView.endSelection() + } + + override fun onActionItemClicked(mode: ActionMode?, item: MenuItem?): Boolean { + val item1 = item ?: return false + if (item1.itemId == R.id.action_download) { + val selectedMedia = this@ProfileFragment.selectedMedia ?: return false + val context = context ?: return false + DownloadUtils.download(context, selectedMedia) + binding.postsRecyclerView.endSelection() + return true + } + return false + } + } + ) + private val selectionModeCallback = object : FeedAdapterV2.SelectionModeCallback { + override fun onSelectionStart() { + if (!onBackPressedCallback.isEnabled) { + onBackPressedCallback.isEnabled = true + mainActivity.onBackPressedDispatcher.addCallback(viewLifecycleOwner, onBackPressedCallback) + } + if (actionMode == null) { + actionMode = mainActivity.startActionMode(multiSelectAction) + } + } + + override fun onSelectionChange(mediaSet: Set?) { + if (mediaSet == null) { + selectedMedia = null + return + } + val title = getString(R.string.number_selected, mediaSet.size) + actionMode?.title = title + selectedMedia = mediaSet.toList() + } + + override fun onSelectionEnd() { + if (onBackPressedCallback.isEnabled) { + onBackPressedCallback.isEnabled = false + onBackPressedCallback.remove() + } + (actionMode ?: return).finish() + actionMode = null + } + } + private val onProfilePicClickListener = View.OnClickListener { + val hasStories = viewModel.userStories.value?.data?.isNotEmpty() ?: false + if (!hasStories) { + showProfilePicDialog() + return@OnClickListener + } + val dialog = MultiOptionDialogFragment.newInstance( + ppOptsDialogRequestCode, + 0, + arrayListOf( + Option(getString(R.string.view_pfp), "profile_pic"), + Option(getString(R.string.show_stories), "show_stories") + ) + ) + dialog.show(childFragmentManager, MultiOptionDialogFragment::class.java.simpleName) + } + private val onFollowersClickListener = View.OnClickListener { + try { + val action = ProfileFragmentDirections.actionProfileFragmentToFollowViewerFragment( + viewModel.profile.value?.data?.pk ?: return@OnClickListener, + true, + viewModel.profile.value?.data?.username ?: return@OnClickListener + ) + NavHostFragment.findNavController(this).navigate(action) + } catch (e: Exception) { + Log.e(TAG, "onFollowersClickListener: ", e) + } + } + private val onFollowingClickListener = View.OnClickListener { + try { + val action = ProfileFragmentDirections.actionProfileFragmentToFollowViewerFragment( + viewModel.profile.value?.data?.pk ?: return@OnClickListener, + false, + viewModel.profile.value?.data?.username ?: return@OnClickListener + ) + NavHostFragment.findNavController(this).navigate(action) + } catch (e: Exception) { + Log.e(TAG, "onFollowersClickListener: ", e) + } + } + private val onEmailClickListener = OnEmailClickListener { + Utils.openEmailAddress(context ?: return@OnEmailClickListener, it.originalText.trimAll()) + } + private val onHashtagClickListener = OnHashtagClickListener { + try { + val bundle = Bundle() + bundle.putString(ARG_HASHTAG, it.originalText.trimAll()) + NavHostFragment.findNavController(this).navigate(R.id.action_global_hashTagFragment, bundle) + } catch (e: Exception) { + Log.e(TAG, "onHashtagClickListener: ", e) + } + } + private val onMentionClickListener = OnMentionClickListener { + navigateToProfile(it.originalText.trimAll()) + } + private val onURLClickListener = OnURLClickListener { + Utils.openURL(context ?: return@OnURLClickListener, it.originalText.trimAll()) + } + + @Suppress("UNCHECKED_CAST") + private val backStackSavedStateObserver = Observer { result -> + if (result == null) return@Observer + if ((result is RankedRecipient)) { + if (context != null) { + Toast.makeText(context, R.string.sending, Toast.LENGTH_SHORT).show() + } + viewModel.shareDm(result) + } else if ((result is Set<*>)) { + try { + if (context != null) { + Toast.makeText(context, R.string.sending, Toast.LENGTH_SHORT).show() + } + viewModel.shareDm(result as Set) + } catch (e: Exception) { + Log.e(TAG, "share: ", e) + } + } + // clear result + backStackSavedStateResultLiveData?.postValue(null) + } + + private fun openPostDialog(media: Media, position: Int) { + val bundle = Bundle().apply { + putSerializable(PostViewV2Fragment.ARG_MEDIA, media) + putInt(PostViewV2Fragment.ARG_SLIDER_POSITION, position) + } + try { + val navController = NavHostFragment.findNavController(this) + navController.navigate(R.id.action_global_post_view, bundle) + } catch (e: Exception) { + Log.e(TAG, "openPostDialog: ", e) + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + mainActivity = requireActivity() as MainActivity + appStateViewModel = ViewModelProvider(mainActivity).get(AppStateViewModel::class.java) + val cookie = Utils.settingsHelper.getString(Constants.COOKIE) + val deviceUuid = Utils.settingsHelper.getString(Constants.DEVICE_UUID) + val csrfToken = getCsrfTokenFromCookie(cookie) + val userId = getUserIdFromCookie(cookie) + val isLoggedIn = !csrfToken.isNullOrBlank() && userId != 0L && deviceUuid.isNotBlank() + viewModel = ViewModelProvider( + this, + ProfileFragmentViewModelFactory( + csrfToken, + deviceUuid, + UserRepository.getInstance(), + FriendshipRepository.getInstance(), + StoriesRepository.getInstance(), + MediaRepository.getInstance(), + GraphQLRepository.getInstance(), + AccountRepository.getInstance(requireContext()), + FavoriteRepository.getInstance(requireContext()), + DirectMessagesRepository.getInstance(), + if (isLoggedIn) DirectMessagesManager else null, + this, + arguments + ) + ).get(ProfileFragmentViewModel::class.java) + setHasOptionsMenu(true) + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + if (this::root.isInitialized) { + shouldRefresh = false + return root + } + appStateViewModel.currentUserLiveData.observe(viewLifecycleOwner, viewModel::setCurrentUser) + binding = FragmentProfileBinding.inflate(inflater, container, false) + root = binding.root + return root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + if (!shouldRefresh) { + setupObservers() + return + } + init() + shouldRefresh = false + } + + override fun onRefresh() { + viewModel.refresh() + binding.postsRecyclerView.refresh() + } + + override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { + inflater.inflate(R.menu.profile_menu, menu) + blockMenuItem = menu.findItem(R.id.block) + restrictMenuItem = menu.findItem(R.id.restrict) + muteStoriesMenuItem = menu.findItem(R.id.mute_stories) + mutePostsMenuItem = menu.findItem(R.id.mute_posts) + chainingMenuItem = menu.findItem(R.id.chaining) + removeFollowerMenuItem = menu.findItem(R.id.remove_follower) + shareLinkMenuItem = menu.findItem(R.id.share_link) + shareDmMenuItem = menu.findItem(R.id.share_dm) + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + R.id.layout -> showPostsLayoutPreferences() + R.id.restrict -> viewModel.restrictUser() + R.id.block -> viewModel.blockUser() + R.id.chaining -> navigateToChaining() + R.id.mute_stories -> viewModel.muteStories() + R.id.mute_posts -> viewModel.mutePosts() + R.id.remove_follower -> viewModel.removeFollower() + R.id.share_link -> shareProfileLink() + R.id.share_dm -> shareProfileViaDm() + } + return true + } + + override fun onResume() { + super.onResume() + try { + val backStackEntry = NavHostFragment.findNavController(this).currentBackStackEntry + if (backStackEntry != null) { + backStackSavedStateResultLiveData = backStackEntry.savedStateHandle.getLiveData("result") + backStackSavedStateResultLiveData?.observe(viewLifecycleOwner, backStackSavedStateObserver) + } + } catch (e: Exception) { + Log.e(TAG, "onResume: ", e) + } + } + + private fun shareProfileViaDm() { + val actionGlobalUserSearch = UserSearchFragmentDirections.actionGlobalUserSearch().apply { + setTitle(getString(R.string.share)) + setActionLabel(getString(R.string.send)) + showGroups = true + multiple = true + setSearchMode(UserSearchFragment.SearchMode.RAVEN) + } + try { + val navController = NavHostFragment.findNavController(this@ProfileFragment) + navController.navigate(actionGlobalUserSearch) + } catch (e: Exception) { + Log.e(TAG, "shareProfileViaDm: ", e) + } + } + + private fun shareProfileLink() { + val profile = viewModel.profile.value?.data ?: return + val sharingIntent = Intent(Intent.ACTION_SEND) + sharingIntent.type = "text/plain" + sharingIntent.putExtra(Intent.EXTRA_TEXT, "https://instagram.com/" + profile.username) + startActivity(Intent.createChooser(sharingIntent, null)) + } + + private fun navigateToChaining() { + viewModel.currentUser.value?.data ?: return + val profile = viewModel.profile.value?.data ?: return + val bundle = Bundle().apply { + putString("type", "chaining") + putLong("targetId", profile.pk) + } + try { + NavHostFragment.findNavController(this).navigate(R.id.action_global_notificationsViewerFragment, bundle) + } catch (e: Exception) { + Log.e(TAG, "navigateToChaining: ", e) + } + } + + private fun init() { + binding.swipeRefreshLayout.setOnRefreshListener(this) + disableDm = !Utils.isNavRootInCurrentTabs("direct_messages_nav_graph") + setupHighlights() + setupObservers() + } + + private fun setupObservers() { + viewModel.isLoggedIn.observe(viewLifecycleOwner) {} // observe so that `isLoggedIn.value` is correct + viewModel.currentUserProfileActionLiveData.observe(viewLifecycleOwner) { + val (currentUserResource, profileResource) = it + if (currentUserResource.status == Resource.Status.ERROR || profileResource.status == Resource.Status.ERROR) { + context?.let { ctx -> Toast.makeText(ctx, R.string.error_loading_profile, Toast.LENGTH_LONG).show() } + return@observe + } + if (currentUserResource.status == Resource.Status.LOADING || profileResource.status == Resource.Status.LOADING) { + binding.swipeRefreshLayout.isRefreshing = true + return@observe + } + binding.swipeRefreshLayout.isRefreshing = false + val currentUser = currentUserResource.data + val profile = profileResource.data + val stateUsername = arguments?.getString("username") + setupOptionsMenuItems(currentUser, profile) + if (currentUser == null && profile == null && stateUsername.isNullOrBlank()) { + // default anonymous state, show default message + showDefaultMessage() + return@observe + } + if (profile == null && !stateUsername.isNullOrBlank()) { + context?.let { ctx -> Toast.makeText(ctx, R.string.error_loading_profile, Toast.LENGTH_LONG).show() } + return@observe + } + root.loadLayoutDescription(R.xml.header_list_scene) + setupFavChip(profile, currentUser) + setupFavButton(currentUser, profile) + setupSavedButton(currentUser, profile) + setupTaggedButton(currentUser, profile) + setupLikedButton(currentUser, profile) + setupDMButton(currentUser, profile) + if (profile == null) return@observe + if (profile.isReallyPrivate(currentUser)) { + showPrivateAccountMessage() + return@observe + } + if (!setupPostsDone) { + setupPosts(profile, currentUser) + } + } + viewModel.username.observe(viewLifecycleOwner) { + mainActivity.supportActionBar?.title = it + mainActivity.supportActionBar?.subtitle = null + } + viewModel.profilePicUrl.observe(viewLifecycleOwner) { + val visibility = if (it.isNullOrBlank()) View.INVISIBLE else View.VISIBLE + binding.header.mainProfileImage.visibility = visibility + binding.header.mainProfileImage.setImageURI(if (it.isNullOrBlank()) null else it) + binding.header.mainProfileImage.setOnClickListener(if (it.isNullOrBlank()) null else onProfilePicClickListener) + } + viewModel.fullName.observe(viewLifecycleOwner) { binding.header.mainFullName.text = it ?: "" } + viewModel.biography.observe(viewLifecycleOwner, this::setupBiography) + viewModel.url.observe(viewLifecycleOwner, this::setupProfileURL) + viewModel.followersCount.observe(viewLifecycleOwner, this::setupFollowers) + viewModel.followingCount.observe(viewLifecycleOwner, this::setupFollowing) + viewModel.postCount.observe(viewLifecycleOwner, this::setupPostsCount) + viewModel.friendshipStatus.observe(viewLifecycleOwner) { + setupFollowButton(it) + setupMainStatus(it) + } + viewModel.isVerified.observe(viewLifecycleOwner) { + binding.header.isVerified.visibility = if (it == true) View.VISIBLE else View.GONE + } + viewModel.isPrivate.observe(viewLifecycleOwner) { + binding.header.isPrivate.visibility = if (it == true) View.VISIBLE else View.GONE + } + viewModel.isFavorite.observe(viewLifecycleOwner) { + if (!it) { + binding.header.favChip.setChipIconResource(R.drawable.ic_outline_star_plus_24) + binding.header.favChip.setText(R.string.add_to_favorites) + return@observe + } + binding.header.favChip.setChipIconResource(R.drawable.ic_star_check_24) + binding.header.favChip.setText(R.string.favorite_short) + } + viewModel.profileContext.observe(viewLifecycleOwner, this::setupProfileContext) + viewModel.userHighlights.observe(viewLifecycleOwner) { + binding.header.highlightsList.visibility = if (it.data.isNullOrEmpty()) View.GONE else View.VISIBLE + highlightsAdapter?.submitList(it.data) + } + viewModel.userStories.observe(viewLifecycleOwner) { + binding.header.mainProfileImage.setStoriesBorder(if (it.data.isNullOrEmpty()) 0 else 1) + } + viewModel.eventLiveData.observe(viewLifecycleOwner) { + val event = it?.getContentIfNotHandled() ?: return@observe + when (event) { + ShowConfirmUnfollowDialog -> showConfirmUnfollowDialog() + is DMButtonState -> binding.header.btnDM.isEnabled = !event.disabled + is NavigateToThread -> mainActivity.navigateToThread(event.threadId, event.username) + is ShowTranslation -> showTranslationDialog(event.result) + } + } + } + + private fun showPrivateAccountMessage() { + binding.header.mainFollowers.isClickable = false + binding.header.mainFollowing.isClickable = false + binding.privatePage1.setImageResource(R.drawable.lock) + binding.privatePage2.setText(R.string.priv_acc) + binding.privatePage.visibility = VISIBLE + binding.privatePage1.visibility = VISIBLE + binding.privatePage2.visibility = VISIBLE + binding.postsRecyclerView.visibility = GONE + binding.swipeRefreshLayout.isRefreshing = false + root.getTransition(R.id.transition)?.setEnable(false) + } + + private fun setupProfileContext(contextPair: Pair?>) { + val (profileContext, contextLinkList) = contextPair + if (profileContext == null || contextLinkList == null) { + binding.header.profileContext.visibility = GONE + binding.header.profileContext.clearOnMentionClickListeners() + return + } + var updatedProfileContext: String = profileContext + contextLinkList.forEachIndexed { i, link -> + if (link.username == null) return@forEachIndexed + updatedProfileContext = updatedProfileContext.substring(0, link.start + i) + "@" + updatedProfileContext.substring(link.start + i) + } + binding.header.profileContext.visibility = VISIBLE + binding.header.profileContext.text = updatedProfileContext + binding.header.profileContext.addOnMentionClickListener(onMentionClickListener) + } + + private fun setupProfileURL(url: String?) { + if (url.isNullOrBlank()) { + binding.header.mainUrl.visibility = GONE + binding.header.mainUrl.clearOnURLClickListeners() + binding.header.mainUrl.setOnLongClickListener(null) + return + } + binding.header.mainUrl.visibility = VISIBLE + binding.header.mainUrl.text = url + binding.header.mainUrl.addOnURLClickListener { Utils.openURL(context ?: return@addOnURLClickListener, it.originalText.trimAll()) } + binding.header.mainUrl.setOnLongClickListener { + Utils.copyText(context ?: return@setOnLongClickListener false, url.trimAll()) + return@setOnLongClickListener true + } + } + + private fun showTranslationDialog(result: String) { + val dialog = ConfirmDialogFragment.newInstance( + translationDialogRequestCode, + 0, + result, + R.string.ok, + 0, + 0 + ) + dialog.show(childFragmentManager, ConfirmDialogFragment::class.java.simpleName) + } + + private fun setupBiography(bio: String?) { + if (bio.isNullOrBlank()) { + binding.header.mainBiography.visibility = View.GONE + binding.header.mainBiography.clearAllAutoLinkListeners() + binding.header.mainBiography.setOnLongClickListener(null) + return + } + binding.header.mainBiography.visibility = View.VISIBLE + binding.header.mainBiography.text = bio + setCommonAutoLinkListeners(binding.header.mainBiography) + binding.header.mainBiography.setOnLongClickListener { + val isLoggedIn = viewModel.isLoggedIn.value ?: false + val options = arrayListOf(Option(getString(R.string.bio_copy), "copy")) + if (isLoggedIn) { + options.add(Option(getString(R.string.bio_translate), "translate")) + } + val dialog = MultiOptionDialogFragment.newInstance( + bioDialogRequestCode, + 0, + options + ) + dialog.show(childFragmentManager, MultiOptionDialogFragment::class.java.simpleName) + return@setOnLongClickListener true + } + } + + private fun setCommonAutoLinkListeners(textView: RamboTextViewV2) { + textView.addOnEmailClickListener(onEmailClickListener) + textView.addOnHashtagListener(onHashtagClickListener) + textView.addOnMentionClickListener(onMentionClickListener) + textView.addOnURLClickListener(onURLClickListener) + } + + private fun setupOptionsMenuItems(currentUser: User?, profile: User?) { + val isMe = currentUser?.pk == profile?.pk + if (profile == null || (currentUser != null && isMe)) { + hideAllOptionsMenuItems() + return + } + if (currentUser == null) { + hideAllOptionsMenuItems() + shareLinkMenuItem?.isVisible = profile.username.isNotBlank() + return + } + + blockMenuItem?.isVisible = true + blockMenuItem?.setTitle(if (profile.friendshipStatus?.blocking == true) R.string.unblock else R.string.block) + + restrictMenuItem?.isVisible = true + restrictMenuItem?.setTitle(if (profile.friendshipStatus?.isRestricted == true) R.string.unrestrict else R.string.restrict) + + muteStoriesMenuItem?.isVisible = true + muteStoriesMenuItem?.setTitle(if (profile.friendshipStatus?.isMutingReel == true) R.string.mute_stories else R.string.unmute_stories) + + mutePostsMenuItem?.isVisible = true + mutePostsMenuItem?.setTitle(if (profile.friendshipStatus?.muting == true) R.string.mute_posts else R.string.unmute_posts) + + chainingMenuItem?.isVisible = profile.hasChaining + removeFollowerMenuItem?.isVisible = profile.friendshipStatus?.followedBy ?: false + shareLinkMenuItem?.isVisible = profile.username.isNotBlank() + shareDmMenuItem?.isVisible = profile.pk != 0L + } + + private fun hideAllOptionsMenuItems() { + blockMenuItem?.isVisible = false + restrictMenuItem?.isVisible = false + muteStoriesMenuItem?.isVisible = false + mutePostsMenuItem?.isVisible = false + chainingMenuItem?.isVisible = false + removeFollowerMenuItem?.isVisible = false + shareLinkMenuItem?.isVisible = false + shareDmMenuItem?.isVisible = false + } + + private fun setupPostsCount(count: Long?) { + if (count == null) { + binding.header.mainPostCount.visibility = View.GONE + return + } + binding.header.mainPostCount.visibility = View.VISIBLE + binding.header.mainPostCount.text = getCountSpan(R.plurals.main_posts_count, abbreviate(count, null), count) + } + + private fun setupFollowing(count: Long?) { + if (count == null) { + binding.header.mainFollowing.visibility = View.GONE + return + } + val abbreviate = abbreviate(count, null) + val span = SpannableStringBuilder(getString(R.string.main_posts_following, abbreviate)) + binding.header.mainFollowing.visibility = View.VISIBLE + binding.header.mainFollowing.text = getCountSpan(span, abbreviate) + if (count <= 0) { + binding.header.mainFollowing.setOnClickListener(null) + return + } + binding.header.mainFollowing.setOnClickListener(onFollowingClickListener) + } + + private fun setupFollowers(count: Long?) { + if (count == null) { + binding.header.mainFollowers.visibility = View.GONE + return + } + binding.header.mainFollowers.visibility = View.VISIBLE + binding.header.mainFollowers.text = getCountSpan(R.plurals.main_posts_followers, abbreviate(count, null), count) + if (count <= 0) { + binding.header.mainFollowers.setOnClickListener(null) + return + } + binding.header.mainFollowers.setOnClickListener(onFollowersClickListener) + } + + private fun setupDMButton(currentUser: User?, profile: User?) { + val visibility = if (disableDm || (currentUser != null && profile?.pk == currentUser.pk)) View.GONE else View.VISIBLE + binding.header.btnDM.visibility = visibility + if (visibility == View.GONE) { + binding.header.btnDM.setOnClickListener(null) + return + } + binding.header.btnDM.setOnClickListener { viewModel.sendDm() } + } + + private fun setupLikedButton(currentUser: User?, profile: User?) { + val visibility = if (currentUser != null && profile?.pk == currentUser.pk) View.VISIBLE else View.GONE + binding.header.btnLiked.visibility = visibility + if (visibility == View.GONE) { + binding.header.btnLiked.setOnClickListener(null) + return + } + binding.header.btnLiked.setOnClickListener { + try { + val action = ProfileFragmentDirections.actionProfileFragmentToSavedViewerFragment( + viewModel.profile.value?.data?.username ?: return@setOnClickListener, + viewModel.profile.value?.data?.pk ?: return@setOnClickListener, + PostItemType.LIKED + ) + NavHostFragment.findNavController(this).navigate(action) + } catch (e: Exception) { + Log.e(TAG, "setupTaggedButton: ", e) + } + } + } + + private fun setupTaggedButton(currentUser: User?, profile: User?) { + val visibility = if (currentUser != null && profile?.pk == currentUser.pk) View.VISIBLE else View.GONE + binding.header.btnTagged.visibility = visibility + if (visibility == View.GONE) { + binding.header.btnTagged.setOnClickListener(null) + return + } + binding.header.btnTagged.setOnClickListener { + try { + val action = ProfileFragmentDirections.actionProfileFragmentToSavedViewerFragment( + viewModel.profile.value?.data?.username ?: return@setOnClickListener, + viewModel.profile.value?.data?.pk ?: return@setOnClickListener, + PostItemType.TAGGED + ) + NavHostFragment.findNavController(this).navigate(action) + } catch (e: Exception) { + Log.e(TAG, "setupTaggedButton: ", e) + } + } + } + + private fun setupSavedButton(currentUser: User?, profile: User?) { + val visibility = if (currentUser != null && profile?.pk == currentUser.pk) View.VISIBLE else View.GONE + binding.header.btnSaved.visibility = visibility + if (visibility == View.GONE) { + binding.header.btnSaved.setOnClickListener(null) + return + } + binding.header.btnSaved.setOnClickListener { + try { + val action = ProfileFragmentDirections.actionGlobalSavedCollectionsFragment(false) + NavHostFragment.findNavController(this).navigate(action) + } catch (e: Exception) { + Log.e(TAG, "setupSavedButton: ", e) + } + } + } + + private fun setupFavButton(currentUser: User?, profile: User?) { + val visibility = if (currentUser != null && profile?.pk != currentUser.pk) View.VISIBLE else View.GONE + binding.header.btnFollow.visibility = visibility + if (visibility == View.GONE) { + binding.header.btnFollow.setOnClickListener(null) + return + } + binding.header.btnFollow.setOnClickListener { viewModel.toggleFollow(false) } + } + + private fun setupFavChip(profile: User?, currentUser: User?) { + val visibility = if (profile?.pk != currentUser?.pk) View.VISIBLE else View.GONE + binding.header.favChip.visibility = visibility + if (visibility == View.GONE) { + binding.header.favChip.setOnClickListener(null) + return + } + binding.header.favChip.setOnClickListener { viewModel.toggleFavorite() } + } + + private fun setupFollowButton(it: FriendshipStatus?) { + if (it == null) return + if (it.following) { + binding.header.btnFollow.setText(R.string.unfollow) + binding.header.btnFollow.setChipIconResource(R.drawable.ic_outline_person_add_disabled_24) + return + } + if (it.outgoingRequest) { + binding.header.btnFollow.setText(R.string.cancel) + binding.header.btnFollow.setChipIconResource(R.drawable.ic_outline_person_add_disabled_24) + return + } + binding.header.btnFollow.setText(R.string.follow) + binding.header.btnFollow.setChipIconResource(R.drawable.ic_outline_person_add_24) + } + + private fun setupMainStatus(it: FriendshipStatus?) { + if (it == null || (!it.following && !it.followedBy)) { + binding.header.mainStatus.visibility = View.GONE + return + } + binding.header.mainStatus.visibility = View.VISIBLE + if (it.following && it.followedBy) { + context?.let { ctx -> + binding.header.mainStatus.chipBackgroundColor = AppCompatResources.getColorStateList(ctx, R.color.green_800) + binding.header.mainStatus.setText(R.string.status_mutual) + } + return + } + if (it.following) { + context?.let { ctx -> + binding.header.mainStatus.chipBackgroundColor = AppCompatResources.getColorStateList(ctx, R.color.deep_orange_800) + binding.header.mainStatus.setText(R.string.status_following) + } + return + } + context?.let { ctx -> + binding.header.mainStatus.chipBackgroundColor = AppCompatResources.getColorStateList(ctx, R.color.blue_800) + binding.header.mainStatus.setText(R.string.status_follower) + } + } + + private fun getCountSpan(pluralRes: Int, countString: String, count: Long): SpannableStringBuilder { + val span = SpannableStringBuilder(resources.getQuantityString(pluralRes, count.toInt(), countString)) + return getCountSpan(span, countString) + } + + private fun getCountSpan(span: SpannableStringBuilder, countString: String): SpannableStringBuilder { + span.setSpan(RelativeSizeSpan(1.2f), 0, countString.length, 0) + span.setSpan(StyleSpan(Typeface.BOLD), 0, countString.length, 0) + return span + } + + private fun showDefaultMessage() { + root.loadLayoutDescription(R.xml.profile_fragment_no_acc_layout) + binding.privatePage1.visibility = View.VISIBLE + binding.privatePage2.visibility = View.VISIBLE + binding.privatePage1.setImageResource(R.drawable.ic_outline_info_24) + binding.privatePage2.setText(R.string.no_acc) + } + + private fun setupHighlights() { + val context = context ?: return + highlightsAdapter = HighlightsAdapter { model, position -> + val options = StoryViewerOptions.forHighlight(model.title) + options.currentFeedStoryIndex = position + val action = ProfileFragmentDirections.actionProfileFragmentToStoryViewerFragment(options) + NavHostFragment.findNavController(this).navigate(action) + } + binding.header.highlightsList.layoutManager = LinearLayoutManager(context, RecyclerView.HORIZONTAL, false) + binding.header.highlightsList.adapter = highlightsAdapter + } + + private fun setupPosts(profile: User, currentUser: User?) { + binding.postsRecyclerView.setViewModelStoreOwner(this) + .setLifeCycleOwner(this) + .setPostFetchService(ProfilePostFetchService(profile, currentUser != null)) + .setLayoutPreferences(layoutPreferences) + .addFetchStatusChangeListener { binding.swipeRefreshLayout.isRefreshing = it } + .setFeedItemCallback(feedItemCallback) + .setSelectionModeCallback(selectionModeCallback) + .init() + binding.postsRecyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() { + override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { + super.onScrolled(recyclerView, dx, dy) + val canScrollVertically = recyclerView.canScrollVertically(-1) + root.getTransition(R.id.transition)?.setEnable(!canScrollVertically) + } + }) + setupPostsDone = true + } + + private fun navigateToProfile(username: String?) { + try { + val bundle = Bundle() + bundle.putString("username", username ?: return) + val navController = NavHostFragment.findNavController(this) + navController.navigate(R.id.action_global_profileFragment, bundle) + } catch (e: Exception) { + Log.e(TAG, "navigateToProfile: ", e) + } + } + + private fun showConfirmUnfollowDialog() { + val isPrivate = viewModel.profile.value?.data?.isPrivate ?: return + val titleRes = if (isPrivate) R.string.priv_acc else 0 + val messageRes = if (isPrivate) R.string.priv_acc_confirm else R.string.are_you_sure + val dialog = ConfirmDialogFragment.newInstance( + confirmDialogFragmentRequestCode, + titleRes, + messageRes, + R.string.confirm, + R.string.cancel, + 0, + ) + dialog.show(childFragmentManager, ConfirmDialogFragment::class.java.simpleName) + } + + override fun onPositiveButtonClicked(requestCode: Int) { + when (requestCode) { + confirmDialogFragmentRequestCode -> { + viewModel.toggleFollow(true) + } + } + } + + override fun onNegativeButtonClicked(requestCode: Int) {} + + override fun onNeutralButtonClicked(requestCode: Int) {} + + override fun onSelect(requestCode: Int, result: String?) { + val r = result ?: return + when (requestCode) { + ppOptsDialogRequestCode -> onPpOptionSelect(r) + bioDialogRequestCode -> onBioOptionSelect(r) + } + } + + private fun onBioOptionSelect(result: String) { + when (result) { + "copy" -> Utils.copyText(context ?: return, viewModel.biography.value ?: return) + "translate" -> viewModel.translateBio() + } + } + + private fun onPpOptionSelect(result: String) { + when (result) { + "profile_pic" -> showProfilePicDialog() + "show_stories" -> { + try { + val action = ProfileFragmentDirections.actionProfileFragmentToStoryViewerFragment( + StoryViewerOptions.forUser( + viewModel.profile.value?.data?.pk ?: return, + viewModel.profile.value?.data?.fullName ?: return, + ) + ) + NavHostFragment.findNavController(this).navigate(action) + } catch (e: Exception) { + Log.e(TAG, "omPpOptionSelect: ", e) + } + } + } + } + + override fun onCancel(requestCode: Int) {} + + private fun showProfilePicDialog() { + val profile = viewModel.profile.value?.data ?: return + val fragment = ProfilePicDialogFragment.getInstance( + profile.pk, + profile.username, + profile.profilePicUrl ?: return + ) + val ft = childFragmentManager.beginTransaction() + ft.setTransition(FragmentTransaction.TRANSIT_FRAGMENT_OPEN) + .add(fragment, ProfilePicDialogFragment::class.java.simpleName) + .commit() + } + + private fun showPostsLayoutPreferences() { + val fragment = PostsLayoutPreferencesDialogFragment(Constants.PREF_PROFILE_POSTS_LAYOUT) { preferences -> + layoutPreferences = preferences + Handler(Looper.getMainLooper()).postDelayed( + { binding.postsRecyclerView.layoutPreferences = preferences }, + 200 + ) + } + fragment.show(childFragmentManager, PostsLayoutPreferencesDialogFragment::class.java.simpleName) + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/managers/DirectMessagesManager.kt b/app/src/main/java/awais/instagrabber/managers/DirectMessagesManager.kt index bd56b2b1..2878e5fc 100644 --- a/app/src/main/java/awais/instagrabber/managers/DirectMessagesManager.kt +++ b/app/src/main/java/awais/instagrabber/managers/DirectMessagesManager.kt @@ -4,11 +4,11 @@ import android.content.ContentResolver import android.util.Log import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData -import awais.instagrabber.models.enums.BroadcastItemType import awais.instagrabber.models.Resource import awais.instagrabber.models.Resource.Companion.error import awais.instagrabber.models.Resource.Companion.loading import awais.instagrabber.models.Resource.Companion.success +import awais.instagrabber.models.enums.BroadcastItemType import awais.instagrabber.repositories.requests.directmessages.ThreadIdsOrUserIds import awais.instagrabber.repositories.responses.User import awais.instagrabber.repositories.responses.directmessages.DirectThread @@ -17,7 +17,7 @@ import awais.instagrabber.utils.Constants import awais.instagrabber.utils.Utils import awais.instagrabber.utils.getCsrfTokenFromCookie import awais.instagrabber.utils.getUserIdFromCookie -import awais.instagrabber.webservices.DirectMessagesService +import awais.instagrabber.webservices.DirectMessagesRepository import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -31,6 +31,7 @@ object DirectMessagesManager { private val viewerId: Long private val deviceUuid: String private val csrfToken: String + private val directMessagesRepository by lazy { DirectMessagesRepository.getInstance() } fun moveThreadFromPending(threadId: String) { val pendingThreads = pendingInboxManager.threads.value ?: return @@ -66,7 +67,8 @@ object DirectMessagesManager { return ThreadManager(threadId, pending, currentUser, contentResolver, viewerId, csrfToken, deviceUuid) } - suspend fun createThread(userPk: Long): DirectThread = DirectMessagesService.createThread(csrfToken, viewerId, deviceUuid, listOf(userPk), null) + suspend fun createThread(userPk: Long): DirectThread = + directMessagesRepository.createThread(csrfToken, viewerId, deviceUuid, listOf(userPk), null) fun sendMedia(recipient: RankedRecipient, mediaId: String, itemType: BroadcastItemType, scope: CoroutineScope) { sendMedia(setOf(recipient), mediaId, itemType, scope) @@ -78,9 +80,9 @@ object DirectMessagesManager { itemType: BroadcastItemType, scope: CoroutineScope, ) { - val threadIds = recipients.mapNotNull{ it.thread?.threadId } - val userIdsTemp = recipients.mapNotNull{ it.user?.pk } - val userIds = userIdsTemp.map{ listOf(it.toString(10)) } + val threadIds = recipients.mapNotNull { it.thread?.threadId } + val userIdsTemp = recipients.mapNotNull { it.user?.pk } + val userIds = userIdsTemp.map { listOf(it.toString(10)) } sendMedia(threadIds, userIds, mediaId, itemType, scope) { inboxManager.refresh(scope) } @@ -99,7 +101,7 @@ object DirectMessagesManager { scope.launch(Dispatchers.IO) { try { if (itemType == BroadcastItemType.MEDIA_SHARE) - DirectMessagesService.broadcastMediaShare( + directMessagesRepository.broadcastMediaShare( csrfToken, viewerId, deviceUuid, @@ -108,7 +110,7 @@ object DirectMessagesManager { mediaId ) if (itemType == BroadcastItemType.PROFILE) - DirectMessagesService.broadcastProfile( + directMessagesRepository.broadcastProfile( csrfToken, viewerId, deviceUuid, diff --git a/app/src/main/java/awais/instagrabber/managers/InboxManager.kt b/app/src/main/java/awais/instagrabber/managers/InboxManager.kt index 9769ec93..f7dd9681 100644 --- a/app/src/main/java/awais/instagrabber/managers/InboxManager.kt +++ b/app/src/main/java/awais/instagrabber/managers/InboxManager.kt @@ -13,7 +13,7 @@ import awais.instagrabber.repositories.responses.User import awais.instagrabber.repositories.responses.directmessages.* import awais.instagrabber.utils.* import awais.instagrabber.utils.extensions.TAG -import awais.instagrabber.webservices.DirectMessagesService +import awais.instagrabber.webservices.DirectMessagesRepository import com.google.common.cache.CacheBuilder import com.google.common.cache.CacheLoader import com.google.common.collect.ImmutableList @@ -25,8 +25,7 @@ import java.util.* import java.util.concurrent.TimeUnit class InboxManager(private val pending: Boolean) { - // private val fetchInboxControlledRunner: ControlledRunner> = ControlledRunner() - // private val fetchPendingInboxControlledRunner: ControlledRunner> = ControlledRunner() + private val directMessagesRepository by lazy { DirectMessagesRepository.getInstance() } private val inbox = MutableLiveData>(success(null)) private val unseenCount = MutableLiveData>() private val pendingRequestsTotal = MutableLiveData(0) @@ -58,9 +57,9 @@ class InboxManager(private val pending: Boolean) { scope.launch(Dispatchers.IO) { try { val inboxValue = if (pending) { - DirectMessagesService.fetchPendingInbox(cursor, seqId) + directMessagesRepository.fetchPendingInbox(cursor, seqId) } else { - DirectMessagesService.fetchInbox(cursor, seqId) + directMessagesRepository.fetchInbox(cursor, seqId) } parseInboxResponse(inboxValue) } catch (e: Exception) { @@ -77,7 +76,7 @@ class InboxManager(private val pending: Boolean) { unseenCount.postValue(loading(currentUnseenCount)) scope.launch(Dispatchers.IO) { try { - val directBadgeCount = DirectMessagesService.fetchUnseenCount() + val directBadgeCount = directMessagesRepository.fetchUnseenCount() unseenCount.postValue(success(directBadgeCount.badgeCount)) } catch (e: Exception) { Log.e(TAG, "Failed fetching unseen count", e) @@ -253,7 +252,7 @@ class InboxManager(private val pending: Boolean) { try { val clone = currentDirectInbox.clone() as DirectInbox clone.threads = threadsCopy - inbox.setValue(success(clone)) + inbox.postValue(success(clone)) } catch (e: CloneNotSupportedException) { Log.e(TAG, "setThread: ", e) } diff --git a/app/src/main/java/awais/instagrabber/managers/ThreadManager.kt b/app/src/main/java/awais/instagrabber/managers/ThreadManager.kt index 88517de9..b1cf4bf6 100644 --- a/app/src/main/java/awais/instagrabber/managers/ThreadManager.kt +++ b/app/src/main/java/awais/instagrabber/managers/ThreadManager.kt @@ -30,7 +30,7 @@ import awais.instagrabber.utils.MediaUtils.OnInfoLoadListener import awais.instagrabber.utils.MediaUtils.VideoInfo import awais.instagrabber.utils.TextUtils.isEmpty import awais.instagrabber.utils.extensions.TAG -import awais.instagrabber.webservices.DirectMessagesService +import awais.instagrabber.webservices.DirectMessagesRepository import awais.instagrabber.webservices.FriendshipRepository import awais.instagrabber.webservices.MediaRepository import com.google.common.collect.ImmutableList @@ -64,6 +64,7 @@ class ThreadManager( private val threadIdsOrUserIds: ThreadIdsOrUserIds = of(threadId) private val friendshipRepository: FriendshipRepository by lazy { FriendshipRepository.getInstance() } private val mediaRepository: MediaRepository by lazy { MediaRepository.getInstance() } + private val directMessagesRepository by lazy { DirectMessagesRepository.getInstance() } val thread: LiveData by lazy { distinctUntilChanged(map(inboxManager.getInbox()) { inboxResource: Resource? -> @@ -128,7 +129,7 @@ class ThreadManager( _fetching.postValue(loading(null)) scope.launch(Dispatchers.IO) { try { - val threadFeedResponse = DirectMessagesService.fetchThread(threadId, cursor) + val threadFeedResponse = directMessagesRepository.fetchThread(threadId, cursor) if (threadFeedResponse.status != null && threadFeedResponse.status != "ok") { _fetching.postValue(error(R.string.generic_not_ok_response, null)) return@launch @@ -156,7 +157,7 @@ class ThreadManager( if (isGroup == null || !isGroup) return scope.launch(Dispatchers.IO) { try { - val response = DirectMessagesService.participantRequests(threadId, 1) + val response = directMessagesRepository.participantRequests(threadId, 1) _pendingRequests.postValue(response) } catch (e: Exception) { Log.e(TAG, "fetchPendingRequests: ", e) @@ -348,7 +349,7 @@ class ThreadManager( val repliedToClientContext = replyToItemValue?.clientContext scope.launch(Dispatchers.IO) { try { - val response = DirectMessagesService.broadcastText( + val response = directMessagesRepository.broadcastText( csrfToken, viewerId, deviceUuid, @@ -406,7 +407,7 @@ class ThreadManager( data.postValue(loading(directItem)) scope.launch(Dispatchers.IO) { try { - val request = DirectMessagesService.broadcastAnimatedMedia( + val request = directMessagesRepository.broadcastAnimatedMedia( csrfToken, userId, deviceUuid, @@ -455,7 +456,7 @@ class ThreadManager( null ) mediaRepository.uploadFinish(csrfToken, userId, deviceUuid, uploadFinishOptions) - val broadcastResponse = DirectMessagesService.broadcastVoice( + val broadcastResponse = directMessagesRepository.broadcastVoice( csrfToken, viewerId, deviceUuid, @@ -499,7 +500,7 @@ class ThreadManager( } scope.launch(Dispatchers.IO) { try { - DirectMessagesService.broadcastReaction( + directMessagesRepository.broadcastReaction( csrfToken, userId, deviceUuid, @@ -539,7 +540,7 @@ class ThreadManager( } scope.launch(Dispatchers.IO) { try { - DirectMessagesService.broadcastReaction( + directMessagesRepository.broadcastReaction( csrfToken, viewerId, deviceUuid, @@ -567,7 +568,7 @@ class ThreadManager( } scope.launch(Dispatchers.IO) { try { - DirectMessagesService.deleteItem(csrfToken, deviceUuid, threadId, itemId) + directMessagesRepository.deleteItem(csrfToken, deviceUuid, threadId, itemId) } catch (e: Exception) { // add the item back if unsuccessful addItems(index, listOf(item)) @@ -643,7 +644,7 @@ class ThreadManager( } scope.launch(Dispatchers.IO) { try { - DirectMessagesService.forward( + directMessagesRepository.forward( thread.threadId, itemTypeName, threadId, @@ -662,7 +663,7 @@ class ThreadManager( val data = MutableLiveData>() scope.launch(Dispatchers.IO) { try { - DirectMessagesService.approveRequest(csrfToken, deviceUuid, threadId) + directMessagesRepository.approveRequest(csrfToken, deviceUuid, threadId) data.postValue(success(Any())) } catch (e: Exception) { Log.e(TAG, "acceptRequest: ", e) @@ -676,7 +677,7 @@ class ThreadManager( val data = MutableLiveData>() scope.launch(Dispatchers.IO) { try { - DirectMessagesService.declineRequest(csrfToken, deviceUuid, threadId) + directMessagesRepository.declineRequest(csrfToken, deviceUuid, threadId) data.postValue(success(Any())) } catch (e: Exception) { Log.e(TAG, "declineRequest: ", e) @@ -732,7 +733,7 @@ class ThreadManager( if (handleInvalidResponse(data, response)) return@launch val response1 = response.response ?: return@launch val uploadId = response1.optString("upload_id") - val response2 = DirectMessagesService.broadcastPhoto(csrfToken, viewerId, deviceUuid, clientContext, threadIdsOrUserIds, uploadId) + val response2 = directMessagesRepository.broadcastPhoto(csrfToken, viewerId, deviceUuid, clientContext, threadIdsOrUserIds, uploadId) parseResponse(response2, data, directItem) } catch (e: Exception) { data.postValue(error(e.message, null)) @@ -793,7 +794,7 @@ class ThreadManager( VideoOptions(duration / 1000f, emptyList(), 0, false) ) mediaRepository.uploadFinish(csrfToken, userId, deviceUuid, uploadFinishOptions) - val broadcastResponse = DirectMessagesService.broadcastVideo( + val broadcastResponse = directMessagesRepository.broadcastVideo( csrfToken, viewerId, deviceUuid, @@ -923,7 +924,7 @@ class ThreadManager( val data = MutableLiveData>() scope.launch(Dispatchers.IO) { try { - val response = DirectMessagesService.updateTitle(csrfToken, deviceUuid, threadId, newTitle.trim()) + val response = directMessagesRepository.updateTitle(csrfToken, deviceUuid, threadId, newTitle.trim()) handleDetailsChangeResponse(data, response) } catch (e: Exception) { } @@ -935,7 +936,7 @@ class ThreadManager( val data = MutableLiveData>() scope.launch(Dispatchers.IO) { try { - val response = DirectMessagesService.addUsers( + val response = directMessagesRepository.addUsers( csrfToken, deviceUuid, threadId, @@ -954,7 +955,7 @@ class ThreadManager( val data = MutableLiveData>() scope.launch(Dispatchers.IO) { try { - DirectMessagesService.removeUsers(csrfToken, deviceUuid, threadId, setOf(user.pk)) + directMessagesRepository.removeUsers(csrfToken, deviceUuid, threadId, setOf(user.pk)) data.postValue(success(Any())) var activeUsers = users.value var leftUsersValue = leftUsers.value @@ -989,7 +990,7 @@ class ThreadManager( if (isAdmin(user)) return data scope.launch(Dispatchers.IO) { try { - DirectMessagesService.addAdmins(csrfToken, deviceUuid, threadId, setOf(user.pk)) + directMessagesRepository.addAdmins(csrfToken, deviceUuid, threadId, setOf(user.pk)) val currentAdminIds = adminUserIds.value val updatedAdminIds = ImmutableList.builder() .addAll(currentAdminIds ?: emptyList()) @@ -1017,7 +1018,7 @@ class ThreadManager( if (!isAdmin(user)) return data scope.launch(Dispatchers.IO) { try { - DirectMessagesService.removeAdmins(csrfToken, deviceUuid, threadId, setOf(user.pk)) + directMessagesRepository.removeAdmins(csrfToken, deviceUuid, threadId, setOf(user.pk)) val currentAdmins = adminUserIds.value ?: return@launch val updatedAdminUserIds = currentAdmins.filter { userId1: Long -> userId1 != user.pk } val currentThread = thread.value ?: return@launch @@ -1047,7 +1048,7 @@ class ThreadManager( } scope.launch(Dispatchers.IO) { try { - DirectMessagesService.mute(csrfToken, deviceUuid, threadId) + directMessagesRepository.mute(csrfToken, deviceUuid, threadId) data.postValue(success(Any())) val currentThread = thread.value ?: return@launch try { @@ -1075,7 +1076,7 @@ class ThreadManager( } scope.launch(Dispatchers.IO) { try { - DirectMessagesService.unmute(csrfToken, deviceUuid, threadId) + directMessagesRepository.unmute(csrfToken, deviceUuid, threadId) data.postValue(success(Any())) val currentThread = thread.value ?: return@launch try { @@ -1103,7 +1104,7 @@ class ThreadManager( } scope.launch(Dispatchers.IO) { try { - DirectMessagesService.muteMentions(csrfToken, deviceUuid, threadId) + directMessagesRepository.muteMentions(csrfToken, deviceUuid, threadId) data.postValue(success(Any())) val currentThread = thread.value ?: return@launch try { @@ -1131,7 +1132,7 @@ class ThreadManager( } scope.launch(Dispatchers.IO) { try { - DirectMessagesService.unmuteMentions(csrfToken, deviceUuid, threadId) + directMessagesRepository.unmuteMentions(csrfToken, deviceUuid, threadId) data.postValue(success(Any())) val currentThread = thread.value ?: return@launch try { @@ -1210,7 +1211,7 @@ class ThreadManager( data.postValue(loading(null)) scope.launch(Dispatchers.IO) { try { - val response = DirectMessagesService.approveParticipantRequests( + val response = directMessagesRepository.approveParticipantRequests( csrfToken, deviceUuid, threadId, @@ -1231,7 +1232,7 @@ class ThreadManager( data.postValue(loading(null)) scope.launch(Dispatchers.IO) { try { - val response = DirectMessagesService.declineParticipantRequests( + val response = directMessagesRepository.declineParticipantRequests( csrfToken, deviceUuid, threadId, @@ -1273,7 +1274,7 @@ class ThreadManager( } scope.launch(Dispatchers.IO) { try { - val response = DirectMessagesService.approvalRequired(csrfToken, deviceUuid, threadId) + val response = directMessagesRepository.approvalRequired(csrfToken, deviceUuid, threadId) handleDetailsChangeResponse(data, response) val currentThread = thread.value ?: return@launch try { @@ -1301,7 +1302,7 @@ class ThreadManager( } scope.launch(Dispatchers.IO) { try { - val request = DirectMessagesService.approvalNotRequired(csrfToken, deviceUuid, threadId) + val request = directMessagesRepository.approvalNotRequired(csrfToken, deviceUuid, threadId) handleDetailsChangeResponse(data, request) val currentThread = thread.value ?: return@launch try { @@ -1324,7 +1325,7 @@ class ThreadManager( data.postValue(loading(null)) scope.launch(Dispatchers.IO) { try { - val request = DirectMessagesService.leave(csrfToken, deviceUuid, threadId) + val request = directMessagesRepository.leave(csrfToken, deviceUuid, threadId) handleDetailsChangeResponse(data, request) } catch (e: Exception) { Log.e(TAG, "leave: ", e) @@ -1339,7 +1340,7 @@ class ThreadManager( data.postValue(loading(null)) scope.launch(Dispatchers.IO) { try { - val request = DirectMessagesService.end(csrfToken, deviceUuid, threadId) + val request = directMessagesRepository.end(csrfToken, deviceUuid, threadId) handleDetailsChangeResponse(data, request) val currentThread = thread.value ?: return@launch try { @@ -1376,7 +1377,7 @@ class ThreadManager( data.postValue(loading(null)) scope.launch(Dispatchers.IO) { try { - val response = DirectMessagesService.markAsSeen(csrfToken, deviceUuid, threadId, directItem) + val response = directMessagesRepository.markAsSeen(csrfToken, deviceUuid, threadId, directItem) if (response == null) { data.postValue(error(R.string.generic_null_response, null)) return@launch diff --git a/app/src/main/java/awais/instagrabber/repositories/DirectMessagesRepository.kt b/app/src/main/java/awais/instagrabber/repositories/DirectMessagesService.kt similarity index 99% rename from app/src/main/java/awais/instagrabber/repositories/DirectMessagesRepository.kt rename to app/src/main/java/awais/instagrabber/repositories/DirectMessagesService.kt index 8eae2d16..7d186fc6 100644 --- a/app/src/main/java/awais/instagrabber/repositories/DirectMessagesRepository.kt +++ b/app/src/main/java/awais/instagrabber/repositories/DirectMessagesService.kt @@ -3,7 +3,7 @@ package awais.instagrabber.repositories import awais.instagrabber.repositories.responses.directmessages.* import retrofit2.http.* -interface DirectMessagesRepository { +interface DirectMessagesService { @GET("/api/v1/direct_v2/inbox/") suspend fun fetchInbox(@QueryMap queryMap: Map): DirectInboxResponse diff --git a/app/src/main/java/awais/instagrabber/repositories/responses/UserProfileContextLink.java b/app/src/main/java/awais/instagrabber/repositories/responses/UserProfileContextLink.java deleted file mode 100644 index 7beb954d..00000000 --- a/app/src/main/java/awais/instagrabber/repositories/responses/UserProfileContextLink.java +++ /dev/null @@ -1,21 +0,0 @@ -package awais.instagrabber.repositories.responses; - -public class UserProfileContextLink { - private final String username; - private final int start; - private final int end; - - public UserProfileContextLink(final String username, final int start, final int end) { - this.username = username; - this.start = start; - this.end = end; - } - - public String getUsername() { - return username; - } - - public int getStart() { - return start; - } -} diff --git a/app/src/main/java/awais/instagrabber/repositories/responses/UserProfileContextLink.kt b/app/src/main/java/awais/instagrabber/repositories/responses/UserProfileContextLink.kt new file mode 100644 index 00000000..8b9977a9 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/repositories/responses/UserProfileContextLink.kt @@ -0,0 +1,7 @@ +package awais.instagrabber.repositories.responses + +data class UserProfileContextLink( + val username: String? = null, + val start: Int = 0, + val end: Int = 0, +) \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/utils/Event.kt b/app/src/main/java/awais/instagrabber/utils/Event.kt new file mode 100644 index 00000000..fea0d4f8 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/utils/Event.kt @@ -0,0 +1,27 @@ +package awais.instagrabber.utils + +/** + * Used as a wrapper for data that is exposed via a LiveData that represents an event. + */ +open class Event(private val content: T) { + + var hasBeenHandled = false + private set // Allow external read but not write + + /** + * Returns the content and prevents its use again. + */ + fun getContentIfNotHandled(): T? { + return if (hasBeenHandled) { + null + } else { + hasBeenHandled = true + content + } + } + + /** + * Returns the content, even if it's already been handled. + */ + fun peekContent(): T = content +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/utils/SettingsHelper.kt b/app/src/main/java/awais/instagrabber/utils/SettingsHelper.kt index 826710ab..57f93af3 100755 --- a/app/src/main/java/awais/instagrabber/utils/SettingsHelper.kt +++ b/app/src/main/java/awais/instagrabber/utils/SettingsHelper.kt @@ -5,26 +5,26 @@ import android.content.SharedPreferences import android.os.Build import androidx.annotation.StringDef import androidx.appcompat.app.AppCompatDelegate +import awais.instagrabber.fragments.settings.PreferenceKeys import java.util.* -import awais.instagrabber.fragments.settings.PreferenceKeys - class SettingsHelper(context: Context) { - private val sharedPreferences: SharedPreferences? + private val sharedPreferences: SharedPreferences? = context.getSharedPreferences(Constants.SHARED_PREFERENCES_NAME, Context.MODE_PRIVATE) + fun getString(@StringSettings key: String): String { val stringDefault = getStringDefault(key) - return if (sharedPreferences != null) sharedPreferences.getString( + return sharedPreferences?.getString( key, stringDefault - )!! else stringDefault + ) ?: stringDefault } - fun getStringSet(@StringSetSettings key: String?): Set? { + fun getStringSet(@StringSetSettings key: String?): Set { val stringSetDefault: Set = HashSet() - return if (sharedPreferences != null) sharedPreferences.getStringSet( + return sharedPreferences?.getStringSet( key, stringSetDefault - ) else stringSetDefault + ) ?: stringSetDefault } fun getInteger(@IntegerSettings key: String): Int { @@ -49,15 +49,16 @@ class SettingsHelper(context: Context) { fun getThemeCode(fromHelper: Boolean): Int { var themeCode = AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM if (!fromHelper && sharedPreferences != null) { - themeCode = sharedPreferences.getString(PreferenceKeys.APP_THEME, themeCode.toString())!!.toInt() + themeCode = sharedPreferences.getString(PreferenceKeys.APP_THEME, themeCode.toString())?.toInt() ?: 0 when (themeCode) { 1 -> themeCode = AppCompatDelegate.MODE_NIGHT_AUTO_BATTERY 3 -> themeCode = AppCompatDelegate.MODE_NIGHT_NO 0 -> themeCode = AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM } } - if (themeCode == AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM && Build.VERSION.SDK_INT < 29) themeCode = - AppCompatDelegate.MODE_NIGHT_AUTO_BATTERY + if (themeCode == AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM && Build.VERSION.SDK_INT < 29) { + themeCode = AppCompatDelegate.MODE_NIGHT_AUTO_BATTERY + } return themeCode } @@ -78,7 +79,7 @@ class SettingsHelper(context: Context) { } fun hasPreference(key: String?): Boolean { - return sharedPreferences != null && sharedPreferences.contains(key) + return sharedPreferences?.contains(key) ?: false } @StringDef( @@ -149,8 +150,4 @@ class SettingsHelper(context: Context) { @StringDef(PreferenceKeys.KEYWORD_FILTERS) annotation class StringSetSettings - init { - sharedPreferences = - context.getSharedPreferences(Constants.SHARED_PREFERENCES_NAME, Context.MODE_PRIVATE) - } } \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/utils/SingleLiveEvent.kt b/app/src/main/java/awais/instagrabber/utils/SingleLiveEvent.kt new file mode 100644 index 00000000..61245193 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/utils/SingleLiveEvent.kt @@ -0,0 +1,68 @@ +/* + * Copyright 2017 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package awais.instagrabber.utils + +import android.util.Log +import androidx.annotation.MainThread +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.Observer +import awais.instagrabber.utils.extensions.TAG +import java.util.concurrent.atomic.AtomicBoolean + +/** + * A lifecycle-aware observable that sends only new updates after subscription, used for events like + * navigation and Snackbar messages. + * + * + * This avoids a common problem with events: on configuration change (like rotation) an update + * can be emitted if the observer is active. This LiveData only calls the observable if there's an + * explicit call to setValue() or call(). + * + * + * Note that only one observer is going to be notified of changes. + */ +class SingleLiveEvent : MutableLiveData() { + private val pending = AtomicBoolean(false) + + @MainThread + override fun observe(owner: LifecycleOwner, observer: Observer) { + if (hasActiveObservers()) { + Log.w(TAG, "Multiple observers registered but only one will be notified of changes.") + } + // Observe the internal MutableLiveData + super.observe(owner, { t -> + if (pending.compareAndSet(true, false)) { + observer.onChanged(t) + } + }) + } + + @MainThread + override fun setValue(t: T?) { + pending.set(true) + super.setValue(t) + } + + /** + * Used for cases where T is Void, to make calls cleaner. + */ + @MainThread + fun call() { + value = null + } +} diff --git a/app/src/main/java/awais/instagrabber/utils/extensions/StringExtensions.kt b/app/src/main/java/awais/instagrabber/utils/extensions/StringExtensions.kt new file mode 100644 index 00000000..5d9a1cd6 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/utils/extensions/StringExtensions.kt @@ -0,0 +1,3 @@ +package awais.instagrabber.utils.extensions + +fun String.trimAll() = this.trim { it <= ' ' } \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/utils/extensions/UserExtensions.kt b/app/src/main/java/awais/instagrabber/utils/extensions/UserExtensions.kt new file mode 100644 index 00000000..1b16afb9 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/utils/extensions/UserExtensions.kt @@ -0,0 +1,9 @@ +package awais.instagrabber.utils.extensions + +import awais.instagrabber.repositories.responses.User + +fun User.isReallyPrivate(currentUser: User? = null): Boolean { + if (currentUser == null) return this.isPrivate + if (this.pk == currentUser.pk) return false + return this.friendshipStatus?.following == false && this.isPrivate +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/viewmodels/AppStateViewModel.java b/app/src/main/java/awais/instagrabber/viewmodels/AppStateViewModel.java index 727f6e5a..b471c978 100644 --- a/app/src/main/java/awais/instagrabber/viewmodels/AppStateViewModel.java +++ b/app/src/main/java/awais/instagrabber/viewmodels/AppStateViewModel.java @@ -9,8 +9,10 @@ import androidx.lifecycle.AndroidViewModel; import androidx.lifecycle.LiveData; import androidx.lifecycle.MutableLiveData; +import awais.instagrabber.db.repositories.AccountRepository; import awais.instagrabber.models.Resource; import awais.instagrabber.repositories.responses.User; +import awais.instagrabber.utils.AppExecutors; import awais.instagrabber.utils.Constants; import awais.instagrabber.utils.CookieUtils; import awais.instagrabber.utils.CoroutineUtilsKt; @@ -26,6 +28,8 @@ public class AppStateViewModel extends AndroidViewModel { private final String cookie; private final MutableLiveData> currentUser = new MutableLiveData<>(Resource.loading(null)); + private AccountRepository accountRepository; + private UserRepository userRepository; public AppStateViewModel(@NonNull final Application application) { @@ -38,7 +42,7 @@ public class AppStateViewModel extends AndroidViewModel { return; } userRepository = UserRepository.Companion.getInstance(); - // final AccountRepository accountRepository = AccountRepository.getInstance(AccountDataSource.getInstance(application)); + accountRepository = AccountRepository.Companion.getInstance(application); fetchProfileDetails(); } @@ -61,13 +65,26 @@ public class AppStateViewModel extends AndroidViewModel { userRepository.getUserInfo(uid, CoroutineUtilsKt.getContinuation((user, throwable) -> { if (throwable != null) { Log.e(TAG, "onFailure: ", throwable); - final User backup = currentUser.getValue().data != null ? - currentUser.getValue().data : - new User(uid); + final Resource userResource = currentUser.getValue(); + final User backup = userResource != null && userResource.data != null ? userResource.data : new User(uid); currentUser.postValue(Resource.error(throwable.getMessage(), backup)); return; } currentUser.postValue(Resource.success(user)); + if (accountRepository != null && user != null) { + accountRepository.insertOrUpdateAccount( + user.getPk(), + user.getUsername(), + cookie, + user.getFullName() != null ? user.getFullName() : "", + user.getProfilePicUrl(), + CoroutineUtilsKt.getContinuation((account, throwable1) -> AppExecutors.INSTANCE.getMainThread().execute(() -> { + if (throwable1 != null) { + Log.e(TAG, "updateAccountInfo: ", throwable1); + } + }), Dispatchers.getIO()) + ); + } }, Dispatchers.getIO())); } } diff --git a/app/src/main/java/awais/instagrabber/viewmodels/PostViewV2ViewModel.kt b/app/src/main/java/awais/instagrabber/viewmodels/PostViewV2ViewModel.kt index 51f12618..866dc748 100644 --- a/app/src/main/java/awais/instagrabber/viewmodels/PostViewV2ViewModel.kt +++ b/app/src/main/java/awais/instagrabber/viewmodels/PostViewV2ViewModel.kt @@ -7,11 +7,11 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import awais.instagrabber.R import awais.instagrabber.managers.DirectMessagesManager -import awais.instagrabber.models.enums.BroadcastItemType import awais.instagrabber.models.Resource import awais.instagrabber.models.Resource.Companion.error import awais.instagrabber.models.Resource.Companion.loading import awais.instagrabber.models.Resource.Companion.success +import awais.instagrabber.models.enums.BroadcastItemType import awais.instagrabber.models.enums.MediaItemType import awais.instagrabber.repositories.responses.Caption import awais.instagrabber.repositories.responses.Location @@ -280,9 +280,9 @@ class PostViewV2ViewModel : ViewModel() { } viewModelScope.launch(Dispatchers.IO) { try { - val result = mediaRepository.translate(pk, "1") + val result = mediaRepository.translate(pk, "1") ?: return@launch if (result.isBlank()) { - data.postValue(error("", null)) + // data.postValue(error("", null)) return@launch } data.postValue(success(result)) diff --git a/app/src/main/java/awais/instagrabber/viewmodels/ProfileFragmentViewModel.kt b/app/src/main/java/awais/instagrabber/viewmodels/ProfileFragmentViewModel.kt index 8594212d..e8c0fa24 100644 --- a/app/src/main/java/awais/instagrabber/viewmodels/ProfileFragmentViewModel.kt +++ b/app/src/main/java/awais/instagrabber/viewmodels/ProfileFragmentViewModel.kt @@ -14,58 +14,90 @@ import awais.instagrabber.models.StoryModel import awais.instagrabber.models.enums.BroadcastItemType import awais.instagrabber.models.enums.FavoriteType import awais.instagrabber.repositories.requests.StoryViewerOptions +import awais.instagrabber.repositories.responses.FriendshipStatus import awais.instagrabber.repositories.responses.User +import awais.instagrabber.repositories.responses.UserProfileContextLink import awais.instagrabber.repositories.responses.directmessages.RankedRecipient import awais.instagrabber.utils.ControlledRunner +import awais.instagrabber.utils.Event +import awais.instagrabber.utils.SingleRunner import awais.instagrabber.utils.extensions.TAG +import awais.instagrabber.utils.extensions.isReallyPrivate +import awais.instagrabber.viewmodels.ProfileFragmentViewModel.ProfileAction.* +import awais.instagrabber.viewmodels.ProfileFragmentViewModel.ProfileEvent.* import awais.instagrabber.webservices.* import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import java.time.LocalDateTime class ProfileFragmentViewModel( - state: SavedStateHandle, - userRepository: UserRepository, - friendshipRepository: FriendshipRepository, + private val state: SavedStateHandle, + private val csrfToken: String?, + private val deviceUuid: String?, + private val userRepository: UserRepository, + private val friendshipRepository: FriendshipRepository, private val storiesRepository: StoriesRepository, - mediaRepository: MediaRepository, - graphQLRepository: GraphQLRepository, - accountRepository: AccountRepository, + private val mediaRepository: MediaRepository, + private val graphQLRepository: GraphQLRepository, private val favoriteRepository: FavoriteRepository, + private val directMessagesRepository: DirectMessagesRepository, + private val messageManager: DirectMessagesManager?, ioDispatcher: CoroutineDispatcher, ) : ViewModel() { private val _currentUser = MutableLiveData>(Resource.loading(null)) private val _isFavorite = MutableLiveData(false) - private var messageManager: DirectMessagesManager? = null + private val profileAction = MutableLiveData(INIT) + private val _eventLiveData = MutableLiveData?>() + + enum class ProfileAction { + INIT, + REFRESH, + REFRESH_FRIENDSHIP, + } + + sealed class ProfileEvent { + object ShowConfirmUnfollowDialog : ProfileEvent() + class DMButtonState(val disabled: Boolean) : ProfileEvent() + class NavigateToThread(val threadId: String, val username: String) : ProfileEvent() + class ShowTranslation(val result: String) : ProfileEvent() + } val currentUser: LiveData> = _currentUser val isLoggedIn: LiveData = currentUser.map { it.data != null } val isFavorite: LiveData = _isFavorite + val eventLiveData: LiveData?> = _eventLiveData - private val currentUserAndStateUsernameLiveData: LiveData, Resource>> = - object : MediatorLiveData, Resource>>() { + private val currentUserStateUsernameActionLiveData: LiveData, Resource, ProfileAction>> = + object : MediatorLiveData, Resource, ProfileAction>>() { var user: Resource = Resource.loading(null) var stateUsername: Resource = Resource.loading(null) + var action: ProfileAction = INIT init { addSource(currentUser) { currentUser -> this.user = currentUser - value = currentUser to stateUsername + value = Triple(currentUser, stateUsername, action) } addSource(state.getLiveData("username")) { username -> this.stateUsername = Resource.success(username.substringAfter('@')) - value = user to this.stateUsername + value = Triple(user, this.stateUsername, action) } - // trigger currentUserAndStateUsernameLiveData switch map with a state username success resource + addSource(profileAction) { action -> + this.action = action + value = Triple(user, stateUsername, action) + } + // trigger currentUserStateUsernameActionLiveData switch map with a state username success resource if (!state.contains("username")) { this.stateUsername = Resource.success(null) - value = user to this.stateUsername + value = Triple(user, this.stateUsername, action) } } } private val profileFetchControlledRunner = ControlledRunner() - val profile: LiveData> = currentUserAndStateUsernameLiveData.switchMap { - val (currentUserResource, stateUsernameResource) = it + val profile: LiveData> = currentUserStateUsernameActionLiveData.switchMap { + val (currentUserResource, stateUsernameResource, action) = it liveData>(context = viewModelScope.coroutineContext + ioDispatcher) { if (currentUserResource.status == Resource.Status.LOADING || stateUsernameResource.status == Resource.Status.LOADING) { emit(Resource.loading(null)) @@ -78,33 +110,67 @@ class ProfileFragmentViewModel( return@liveData } try { - val fetchedUser = profileFetchControlledRunner.cancelPreviousThenRun { - return@cancelPreviousThenRun fetchUser(currentUser, userRepository, stateUsername, graphQLRepository) - } - emit(Resource.success(fetchedUser)) - if (fetchedUser != null) { - checkAndInsertFavorite(fetchedUser) + when (action) { + INIT, REFRESH -> { + val fetchedUser = profileFetchControlledRunner.cancelPreviousThenRun { fetchUser(currentUser, stateUsername) } + emit(Resource.success(fetchedUser)) + if (fetchedUser != null) { + checkAndUpdateFavorite(fetchedUser) + } + } + REFRESH_FRIENDSHIP -> { + var profile = profileCopy.value?.data ?: return@liveData + profile = profile.copy(friendshipStatus = userRepository.getUserFriendship(profile.pk)) + emit(Resource.success(profile)) + } } } catch (e: Exception) { - emit(Resource.error(e.message, null)) + emit(Resource.error(e.message, profileCopy.value?.data)) Log.e(TAG, "fetching user: ", e) } } } + val profileCopy = profile + + val currentUserProfileActionLiveData: LiveData, Resource, ProfileAction>> = + object : MediatorLiveData, Resource, ProfileAction>>() { + var currentUser: Resource = Resource.loading(null) + var profile: Resource = Resource.loading(null) + var action: ProfileAction = INIT + + init { + addSource(this@ProfileFragmentViewModel.currentUser) { currentUser -> + this.currentUser = currentUser + value = Triple(currentUser, profile, action) + } + addSource(this@ProfileFragmentViewModel.profile) { profile -> + this.profile = profile + value = Triple(currentUser, this.profile, action) + } + addSource(profileAction) { action -> + this.action = action + value = Triple(currentUser, this.profile, action) + } + } + } private val storyFetchControlledRunner = ControlledRunner?>() - val userStories: LiveData?>> = profile.switchMap { userResource -> + val userStories: LiveData?>> = currentUserProfileActionLiveData.switchMap { currentUserAndProfilePair -> liveData?>>(context = viewModelScope.coroutineContext + ioDispatcher) { + val (currentUserResource, profileResource, action) = currentUserAndProfilePair + if (action != INIT && action != REFRESH) { + return@liveData + } // don't fetch if not logged in - if (isLoggedIn.value != true) { + if (currentUserResource.data == null) { emit(Resource.success(null)) return@liveData } - if (userResource.status == Resource.Status.LOADING) { + if (currentUserResource.status == Resource.Status.LOADING || profileResource.status == Resource.Status.LOADING) { emit(Resource.loading(null)) return@liveData } - val user = userResource.data + val user = profileResource.data if (user == null) { emit(Resource.success(null)) return@liveData @@ -120,18 +186,22 @@ class ProfileFragmentViewModel( } private val highlightsFetchControlledRunner = ControlledRunner?>() - val userHighlights: LiveData?>> = profile.switchMap { userResource -> + val userHighlights: LiveData?>> = currentUserProfileActionLiveData.switchMap { currentUserAndProfilePair -> liveData?>>(context = viewModelScope.coroutineContext + ioDispatcher) { + val (currentUserResource, profileResource, action) = currentUserAndProfilePair + if (action != INIT && action != REFRESH) { + return@liveData + } // don't fetch if not logged in - if (isLoggedIn.value != true) { + if (currentUserResource.data == null) { emit(Resource.success(null)) return@liveData } - if (userResource.status == Resource.Status.LOADING) { + if (currentUserResource.status == Resource.Status.LOADING || profileResource.status == Resource.Status.LOADING) { emit(Resource.loading(null)) return@liveData } - val user = userResource.data + val user = profileResource.data if (user == null) { emit(Resource.success(null)) return@liveData @@ -141,24 +211,25 @@ class ProfileFragmentViewModel( emit(Resource.success(fetchedHighlights)) } catch (e: Exception) { emit(Resource.error(e.message, null)) - Log.e(TAG, "fetching story: ", e) + Log.e(TAG, "fetching highlights: ", e) } } } private suspend fun fetchUser( currentUser: User?, - userRepository: UserRepository, stateUsername: String, - graphQLRepository: GraphQLRepository - ) = if (currentUser != null) { - // logged in - val tempUser = userRepository.getUsernameInfo(stateUsername) - tempUser.friendshipStatus = userRepository.getUserFriendship(tempUser.pk) - tempUser - } else { + ): User { + if (currentUser != null) { + // logged in + val tempUser = userRepository.getUsernameInfo(stateUsername) + if (!tempUser.isReallyPrivate(currentUser)) { + tempUser.friendshipStatus = userRepository.getUserFriendship(tempUser.pk) + } + return tempUser + } // anonymous - graphQLRepository.fetchUser(stateUsername) + return graphQLRepository.fetchUser(stateUsername) } private suspend fun fetchUserStory(fetchedUser: User): List = storiesRepository.getUserStory( @@ -167,7 +238,7 @@ class ProfileFragmentViewModel( private suspend fun fetchUserHighlights(fetchedUser: User): List = storiesRepository.fetchHighlights(fetchedUser.pk) - private suspend fun checkAndInsertFavorite(fetchedUser: User) { + private suspend fun checkAndUpdateFavorite(fetchedUser: User) { try { val favorite = favoriteRepository.getFavorite(fetchedUser.username, FavoriteType.USER) if (favorite == null) { @@ -187,7 +258,260 @@ class ProfileFragmentViewModel( ) } catch (e: Exception) { _isFavorite.postValue(false) - Log.e(TAG, "checkAndInsertFavorite: ", e) + Log.e(TAG, "checkAndUpdateFavorite: ", e) + } + } + + fun setCurrentUser(currentUser: Resource) { + _currentUser.postValue(currentUser) + } + + fun shareDm(result: RankedRecipient) { + val mediaId = profile.value?.data?.pk ?: return + messageManager?.sendMedia(result, mediaId.toString(10), BroadcastItemType.PROFILE, viewModelScope) + } + + fun shareDm(recipients: Set) { + val mediaId = profile.value?.data?.pk ?: return + messageManager?.sendMedia(recipients, mediaId.toString(10), BroadcastItemType.PROFILE, viewModelScope) + } + + fun refresh() { + profileAction.postValue(REFRESH) + } + + private val toggleFavoriteControlledRunner = SingleRunner() + fun toggleFavorite() { + val username = profile.value?.data?.username ?: return + val fullName = profile.value?.data?.fullName ?: return + val profilePicUrl = profile.value?.data?.profilePicUrl ?: return + viewModelScope.launch(Dispatchers.IO) { + toggleFavoriteControlledRunner.afterPrevious { + try { + val favorite = favoriteRepository.getFavorite(username, FavoriteType.USER) + if (favorite == null) { + // insert + favoriteRepository.insertOrUpdateFavorite( + Favorite( + 0, + username, + FavoriteType.USER, + fullName, + profilePicUrl, + LocalDateTime.now() + ) + ) + _isFavorite.postValue(true) + return@afterPrevious + } + // delete + favoriteRepository.deleteFavorite(username, FavoriteType.USER) + _isFavorite.postValue(false) + } catch (e: Exception) { + Log.e(TAG, "checkAndUpdateFavorite: ", e) + } + } + } + } + + private val toggleFollowSingleRunner = SingleRunner() + fun toggleFollow(confirmed: Boolean) { + viewModelScope.launch(Dispatchers.IO) { + toggleFollowSingleRunner.afterPrevious { + try { + val following = profile.value?.data?.friendshipStatus?.following ?: false + val currentUserId = currentUser.value?.data?.pk ?: return@afterPrevious + val targetUserId = profile.value?.data?.pk ?: return@afterPrevious + val csrfToken = csrfToken ?: return@afterPrevious + val deviceUuid = deviceUuid ?: return@afterPrevious + if (following) { + if (!confirmed) { + _eventLiveData.postValue(Event(ShowConfirmUnfollowDialog)) + return@afterPrevious + } + // unfollow + friendshipRepository.unfollow( + csrfToken, + currentUserId, + deviceUuid, + targetUserId + ) + profileAction.postValue(REFRESH_FRIENDSHIP) + return@afterPrevious + } + friendshipRepository.follow( + csrfToken, + currentUserId, + deviceUuid, + targetUserId + ) + profileAction.postValue(REFRESH_FRIENDSHIP) + } catch (e: Exception) { + Log.e(TAG, "toggleFollow: ", e) + } + } + } + } + + private val sendDmSingleRunner = SingleRunner() + fun sendDm() { + viewModelScope.launch(Dispatchers.IO) { + sendDmSingleRunner.afterPrevious { + _eventLiveData.postValue(Event(DMButtonState(true))) + try { + val currentUserId = currentUser.value?.data?.pk ?: return@afterPrevious + val targetUserId = profile.value?.data?.pk ?: return@afterPrevious + val csrfToken = csrfToken ?: return@afterPrevious + val deviceUuid = deviceUuid ?: return@afterPrevious + val username = profile.value?.data?.username ?: return@afterPrevious + val thread = directMessagesRepository.createThread( + csrfToken, + currentUserId, + deviceUuid, + listOf(targetUserId), + null, + ) + val inboxManager = DirectMessagesManager.inboxManager + if (!inboxManager.containsThread(thread.threadId)) { + thread.isTemp = true + inboxManager.addThread(thread, 0) + } + val threadId = thread.threadId ?: return@afterPrevious + _eventLiveData.postValue(Event(NavigateToThread(threadId, username))) + } catch (e: Exception) { + Log.e(TAG, "sendDm: ", e) + } finally { + _eventLiveData.postValue(Event(DMButtonState(false))) + } + } + } + } + + private val restrictUserSingleRunner = SingleRunner() + fun restrictUser() { + if (isLoggedIn.value == false) return + viewModelScope.launch(Dispatchers.IO) { + restrictUserSingleRunner.afterPrevious { + try { + val profile = profile.value?.data ?: return@afterPrevious + friendshipRepository.toggleRestrict( + csrfToken ?: return@afterPrevious, + deviceUuid ?: return@afterPrevious, + profile.pk, + !(profile.friendshipStatus?.isRestricted ?: false), + ) + profileAction.postValue(REFRESH_FRIENDSHIP) + } catch (e: Exception) { + Log.e(TAG, "restrictUser: ", e) + } + } + } + } + + private val blockUserSingleRunner = SingleRunner() + fun blockUser() { + if (isLoggedIn.value == false) return + viewModelScope.launch(Dispatchers.IO) { + blockUserSingleRunner.afterPrevious { + try { + val profile = profile.value?.data ?: return@afterPrevious + friendshipRepository.changeBlock( + csrfToken ?: return@afterPrevious, + currentUser.value?.data?.pk ?: return@afterPrevious, + deviceUuid ?: return@afterPrevious, + profile.friendshipStatus?.blocking ?: return@afterPrevious, + profile.pk + ) + profileAction.postValue(REFRESH_FRIENDSHIP) + } catch (e: Exception) { + Log.e(TAG, "blockUser: ", e) + } + } + } + } + + private val muteStoriesSingleRunner = SingleRunner() + fun muteStories() { + if (isLoggedIn.value == false) return + viewModelScope.launch(Dispatchers.IO) { + muteStoriesSingleRunner.afterPrevious { + try { + val profile = profile.value?.data ?: return@afterPrevious + friendshipRepository.changeMute( + csrfToken ?: return@afterPrevious, + currentUser.value?.data?.pk ?: return@afterPrevious, + deviceUuid ?: return@afterPrevious, + profile.friendshipStatus?.isMutingReel ?: return@afterPrevious, + profile.pk, + true + ) + profileAction.postValue(REFRESH_FRIENDSHIP) + } catch (e: Exception) { + Log.e(TAG, "muteStories: ", e) + } + } + } + } + + private val mutePostsSingleRunner = SingleRunner() + fun mutePosts() { + if (isLoggedIn.value == false) return + viewModelScope.launch(Dispatchers.IO) { + mutePostsSingleRunner.afterPrevious { + try { + val profile = profile.value?.data ?: return@afterPrevious + friendshipRepository.changeMute( + csrfToken ?: return@afterPrevious, + currentUser.value?.data?.pk ?: return@afterPrevious, + deviceUuid ?: return@afterPrevious, + profile.friendshipStatus?.muting ?: return@afterPrevious, + profile.pk, + false + ) + profileAction.postValue(REFRESH_FRIENDSHIP) + } catch (e: Exception) { + Log.e(TAG, "mutePosts: ", e) + } + } + } + } + + private val removeFollowerSingleRunner = SingleRunner() + fun removeFollower() { + if (isLoggedIn.value == false) return + viewModelScope.launch(Dispatchers.IO) { + removeFollowerSingleRunner.afterPrevious { + try { + friendshipRepository.removeFollower( + csrfToken ?: return@afterPrevious, + currentUser.value?.data?.pk ?: return@afterPrevious, + deviceUuid ?: return@afterPrevious, + profile.value?.data?.pk ?: return@afterPrevious + ) + profileAction.postValue(REFRESH_FRIENDSHIP) + } catch (e: Exception) { + Log.e(TAG, "removeFollower: ", e) + } + } + } + } + + private val translateBioSingleRunner = SingleRunner() + fun translateBio() { + if (isLoggedIn.value == false) return + viewModelScope.launch(Dispatchers.IO) { + translateBioSingleRunner.afterPrevious { + try { + val result = mediaRepository.translate( + profile.value?.data?.pk?.toString() ?: return@afterPrevious, + "3" + ) + if (result.isNullOrBlank()) return@afterPrevious + _eventLiveData.postValue(Event(ShowTranslation(result))) + } catch (e: Exception) { + Log.e(TAG, "translateBio: ", e) + } + } } } @@ -196,38 +520,82 @@ class ProfileFragmentViewModel( */ val username: LiveData = Transformations.map(profile) { return@map when (it.status) { - Resource.Status.LOADING, Resource.Status.ERROR -> "" - Resource.Status.SUCCESS -> it.data?.username ?: "" + Resource.Status.ERROR -> "" + Resource.Status.LOADING, Resource.Status.SUCCESS -> it.data?.username ?: "" } } - - init { - // Log.d(TAG, "${state.keys()} $userRepository $friendshipRepository $storiesRepository $mediaRepository") - } - - fun setCurrentUser(currentUser: Resource) { - _currentUser.postValue(currentUser) - } - - fun shareDm(result: RankedRecipient) { - if (messageManager == null) { - messageManager = DirectMessagesManager + val profilePicUrl: LiveData = Transformations.map(profile) { + return@map when (it.status) { + Resource.Status.ERROR -> null + Resource.Status.LOADING, Resource.Status.SUCCESS -> it.data?.profilePicUrl } - val mediaId = profile.value?.data?.pk ?: return - messageManager?.sendMedia(result, mediaId.toString(10), BroadcastItemType.PROFILE, viewModelScope) } - - fun shareDm(recipients: Set) { - if (messageManager == null) { - messageManager = DirectMessagesManager + val fullName: LiveData = Transformations.map(profile) { + return@map when (it.status) { + Resource.Status.ERROR -> "" + Resource.Status.LOADING, Resource.Status.SUCCESS -> it.data?.fullName + } + } + val biography: LiveData = Transformations.map(profile) { + return@map when (it.status) { + Resource.Status.ERROR -> "" + Resource.Status.LOADING, Resource.Status.SUCCESS -> it.data?.biography + } + } + val url: LiveData = Transformations.map(profile) { + return@map when (it.status) { + Resource.Status.ERROR -> "" + Resource.Status.LOADING, Resource.Status.SUCCESS -> it.data?.externalUrl + } + } + val followersCount: LiveData = Transformations.map(profile) { + return@map when (it.status) { + Resource.Status.ERROR -> null + Resource.Status.LOADING, Resource.Status.SUCCESS -> it.data?.followerCount + } + } + val followingCount: LiveData = Transformations.map(profile) { + return@map when (it.status) { + Resource.Status.ERROR -> null + Resource.Status.LOADING, Resource.Status.SUCCESS -> it.data?.followingCount + } + } + val postCount: LiveData = Transformations.map(profile) { + return@map when (it.status) { + Resource.Status.ERROR -> null + Resource.Status.LOADING, Resource.Status.SUCCESS -> it.data?.mediaCount + } + } + val isPrivate: LiveData = Transformations.map(profile) { + return@map when (it.status) { + Resource.Status.ERROR -> null + Resource.Status.LOADING, Resource.Status.SUCCESS -> it.data?.isPrivate + } + } + val isVerified: LiveData = Transformations.map(profile) { + return@map when (it.status) { + Resource.Status.ERROR -> null + Resource.Status.LOADING, Resource.Status.SUCCESS -> it.data?.isVerified + } + } + val friendshipStatus: LiveData = Transformations.map(profile) { + return@map when (it.status) { + Resource.Status.ERROR -> null + Resource.Status.LOADING, Resource.Status.SUCCESS -> it.data?.friendshipStatus + } + } + val profileContext: LiveData?>> = Transformations.map(profile) { + return@map when (it.status) { + Resource.Status.ERROR -> null to null + Resource.Status.LOADING, Resource.Status.SUCCESS -> it.data?.profileContext to it.data?.profileContextLinksWithUserIds } - val mediaId = profile.value?.data?.pk ?: return - messageManager?.sendMedia(recipients, mediaId.toString(10), BroadcastItemType.PROFILE, viewModelScope) } } @Suppress("UNCHECKED_CAST") class ProfileFragmentViewModelFactory( + private val csrfToken: String?, + private val deviceUuid: String?, private val userRepository: UserRepository, private val friendshipRepository: FriendshipRepository, private val storiesRepository: StoriesRepository, @@ -235,6 +603,8 @@ class ProfileFragmentViewModelFactory( private val graphQLRepository: GraphQLRepository, private val accountRepository: AccountRepository, private val favoriteRepository: FavoriteRepository, + private val directMessagesRepository: DirectMessagesRepository, + private val messageManager: DirectMessagesManager?, owner: SavedStateRegistryOwner, defaultArgs: Bundle? = null, ) : AbstractSavedStateViewModelFactory(owner, defaultArgs) { @@ -245,13 +615,16 @@ class ProfileFragmentViewModelFactory( ): T { return ProfileFragmentViewModel( handle, + csrfToken, + deviceUuid, userRepository, friendshipRepository, storiesRepository, mediaRepository, graphQLRepository, - accountRepository, favoriteRepository, + directMessagesRepository, + messageManager, Dispatchers.IO, ) as T } diff --git a/app/src/main/java/awais/instagrabber/viewmodels/UserSearchViewModel.java b/app/src/main/java/awais/instagrabber/viewmodels/UserSearchViewModel.java index d46986e8..c0ecd0b8 100644 --- a/app/src/main/java/awais/instagrabber/viewmodels/UserSearchViewModel.java +++ b/app/src/main/java/awais/instagrabber/viewmodels/UserSearchViewModel.java @@ -31,7 +31,7 @@ import awais.instagrabber.utils.CoroutineUtilsKt; import awais.instagrabber.utils.Debouncer; import awais.instagrabber.utils.RankedRecipientsCache; import awais.instagrabber.utils.TextUtils; -import awais.instagrabber.webservices.DirectMessagesService; +import awais.instagrabber.webservices.DirectMessagesRepository; import awais.instagrabber.webservices.UserRepository; import kotlinx.coroutines.Dispatchers; import okhttp3.ResponseBody; @@ -59,7 +59,7 @@ public class UserSearchViewModel extends ViewModel { private final Debouncer searchDebouncer; private final Set selectedRecipients = new HashSet<>(); private final UserRepository userRepository; - private final DirectMessagesService directMessagesService; + private final DirectMessagesRepository directMessagesRepository; private final RankedRecipientsCache rankedRecipientsCache; public UserSearchViewModel() { @@ -71,7 +71,7 @@ public class UserSearchViewModel extends ViewModel { throw new IllegalArgumentException("User is not logged in!"); } userRepository = UserRepository.Companion.getInstance(); - directMessagesService = DirectMessagesService.INSTANCE; + directMessagesRepository = DirectMessagesRepository.Companion.getInstance(); rankedRecipientsCache = RankedRecipientsCache.INSTANCE; if ((rankedRecipientsCache.isFailed() || rankedRecipientsCache.isExpired()) && !rankedRecipientsCache.isUpdateInitiated()) { updateRankedRecipientCache(); @@ -94,7 +94,7 @@ public class UserSearchViewModel extends ViewModel { private void updateRankedRecipientCache() { rankedRecipientsCache.setUpdateInitiated(true); - directMessagesService.rankedRecipients( + directMessagesRepository.rankedRecipients( null, null, null, @@ -191,7 +191,7 @@ public class UserSearchViewModel extends ViewModel { } private void rankedRecipientSearch() { - directMessagesService.rankedRecipients( + directMessagesRepository.rankedRecipients( searchMode.getName(), showGroups, currentQuery, diff --git a/app/src/main/java/awais/instagrabber/webservices/DirectMessagesService.kt b/app/src/main/java/awais/instagrabber/webservices/DirectMessagesRepository.kt similarity index 86% rename from app/src/main/java/awais/instagrabber/webservices/DirectMessagesService.kt rename to app/src/main/java/awais/instagrabber/webservices/DirectMessagesRepository.kt index 7af8dd65..4a7e124a 100644 --- a/app/src/main/java/awais/instagrabber/webservices/DirectMessagesService.kt +++ b/app/src/main/java/awais/instagrabber/webservices/DirectMessagesRepository.kt @@ -1,6 +1,6 @@ package awais.instagrabber.webservices -import awais.instagrabber.repositories.DirectMessagesRepository +import awais.instagrabber.repositories.DirectMessagesService import awais.instagrabber.repositories.requests.directmessages.* import awais.instagrabber.repositories.responses.directmessages.* import awais.instagrabber.repositories.responses.giphy.GiphyGif @@ -9,8 +9,7 @@ import awais.instagrabber.utils.Utils import org.json.JSONArray import java.util.* -object DirectMessagesService { - private val repository: DirectMessagesRepository = RetrofitFactory.retrofit.create(DirectMessagesRepository::class.java) +open class DirectMessagesRepository(private val service: DirectMessagesService) { suspend fun fetchInbox( cursor: String?, @@ -29,7 +28,7 @@ object DirectMessagesService { if (seqId != 0L) { queryMap["seq_id"] = seqId.toString() } - return repository.fetchInbox(queryMap) + return service.fetchInbox(queryMap) } suspend fun fetchThread( @@ -44,10 +43,10 @@ object DirectMessagesService { if (!cursor.isNullOrBlank()) { queryMap["cursor"] = cursor } - return repository.fetchThread(threadId, queryMap) + return service.fetchThread(threadId, queryMap) } - suspend fun fetchUnseenCount(): DirectBadgeCount = repository.fetchUnseenCount() + suspend fun fetchUnseenCount(): DirectBadgeCount = service.fetchUnseenCount() suspend fun broadcastText( csrfToken: String, @@ -61,7 +60,17 @@ object DirectMessagesService { ): DirectThreadBroadcastResponse { val urls = extractUrls(text) if (urls.isNotEmpty()) { - return broadcastLink(csrfToken, userId, deviceUuid, clientContext, threadIdsOrUserIds, text, urls, repliedToItemId, repliedToClientContext) + return broadcastLink( + csrfToken, + userId, + deviceUuid, + clientContext, + threadIdsOrUserIds, + text, + urls, + repliedToItemId, + repliedToClientContext + ) } val broadcastOptions = TextBroadcastOptions(clientContext, threadIdsOrUserIds, text) if (!repliedToItemId.isNullOrBlank() && !repliedToClientContext.isNullOrBlank()) { @@ -211,7 +220,7 @@ object DirectMessagesService { form.putAll(broadcastOptions.formMap) form["action"] = "send_item" // val signedForm = Utils.sign(form) - return repository.broadcast(broadcastOptions.itemType.value, form) + return service.broadcast(broadcastOptions.itemType.value, form) } suspend fun addUsers( @@ -225,7 +234,7 @@ object DirectMessagesService { "_uuid" to deviceUuid, "user_ids" to JSONArray(userIds).toString(), ) - return repository.addUsers(threadId, form) + return service.addUsers(threadId, form) } suspend fun removeUsers( @@ -239,7 +248,7 @@ object DirectMessagesService { "_uuid" to deviceUuid, "user_ids" to JSONArray(userIds).toString(), ) - return repository.removeUsers(threadId, form) + return service.removeUsers(threadId, form) } suspend fun updateTitle( @@ -253,7 +262,7 @@ object DirectMessagesService { "_uuid" to deviceUuid, "title" to title, ) - return repository.updateTitle(threadId, form) + return service.updateTitle(threadId, form) } suspend fun addAdmins( @@ -267,7 +276,7 @@ object DirectMessagesService { "_uuid" to deviceUuid, "user_ids" to JSONArray(userIds).toString(), ) - return repository.addAdmins(threadId, form) + return service.addAdmins(threadId, form) } suspend fun removeAdmins( @@ -281,7 +290,7 @@ object DirectMessagesService { "_uuid" to deviceUuid, "user_ids" to JSONArray(userIds).toString(), ) - return repository.removeAdmins(threadId, form) + return service.removeAdmins(threadId, form) } suspend fun deleteItem( @@ -294,7 +303,7 @@ object DirectMessagesService { "_csrftoken" to csrfToken, "_uuid" to deviceUuid, ) - return repository.deleteItem(threadId, itemId, form) + return service.deleteItem(threadId, itemId, form) } suspend fun rankedRecipients( @@ -316,7 +325,7 @@ object DirectMessagesService { if (showThreads != null) { queryMap["showThreads"] = showThreads.toString() } - return repository.rankedRecipients(queryMap) + return service.rankedRecipients(queryMap) } suspend fun forward( @@ -332,7 +341,7 @@ object DirectMessagesService { "forwarded_from_thread_id" to fromThreadId, "forwarded_from_thread_item_id" to itemId, ) - return repository.forward(form) + return service.forward(form) } suspend fun createThread( @@ -353,7 +362,7 @@ object DirectMessagesService { form["thread_title"] = threadTitle } val signedForm = Utils.sign(form) - return repository.createThread(signedForm) + return service.createThread(signedForm) } suspend fun mute( @@ -365,7 +374,7 @@ object DirectMessagesService { "_csrftoken" to csrfToken, "_uuid" to deviceUuid ) - return repository.mute(threadId, form) + return service.mute(threadId, form) } suspend fun unmute( @@ -377,7 +386,7 @@ object DirectMessagesService { "_csrftoken" to csrfToken, "_uuid" to deviceUuid, ) - return repository.unmute(threadId, form) + return service.unmute(threadId, form) } suspend fun muteMentions( @@ -389,7 +398,7 @@ object DirectMessagesService { "_csrftoken" to csrfToken, "_uuid" to deviceUuid, ) - return repository.muteMentions(threadId, form) + return service.muteMentions(threadId, form) } suspend fun unmuteMentions( @@ -401,7 +410,7 @@ object DirectMessagesService { "_csrftoken" to csrfToken, "_uuid" to deviceUuid, ) - return repository.unmuteMentions(threadId, form) + return service.unmuteMentions(threadId, form) } suspend fun participantRequests( @@ -409,7 +418,7 @@ object DirectMessagesService { pageSize: Int, cursor: String? = null, ): DirectThreadParticipantRequestsResponse { - return repository.participantRequests(threadId, pageSize, cursor) + return service.participantRequests(threadId, pageSize, cursor) } suspend fun approveParticipantRequests( @@ -424,7 +433,7 @@ object DirectMessagesService { "user_ids" to JSONArray(userIds).toString(), // "share_join_chat_story" to String.valueOf(true) ) - return repository.approveParticipantRequests(threadId, form) + return service.approveParticipantRequests(threadId, form) } suspend fun declineParticipantRequests( @@ -438,7 +447,7 @@ object DirectMessagesService { "_uuid" to deviceUuid, "user_ids" to JSONArray(userIds).toString(), ) - return repository.declineParticipantRequests(threadId, form) + return service.declineParticipantRequests(threadId, form) } suspend fun approvalRequired( @@ -450,7 +459,7 @@ object DirectMessagesService { "_csrftoken" to csrfToken, "_uuid" to deviceUuid, ) - return repository.approvalRequired(threadId, form) + return service.approvalRequired(threadId, form) } suspend fun approvalNotRequired( @@ -462,7 +471,7 @@ object DirectMessagesService { "_csrftoken" to csrfToken, "_uuid" to deviceUuid, ) - return repository.approvalNotRequired(threadId, form) + return service.approvalNotRequired(threadId, form) } suspend fun leave( @@ -474,7 +483,7 @@ object DirectMessagesService { "_csrftoken" to csrfToken, "_uuid" to deviceUuid, ) - return repository.leave(threadId, form) + return service.leave(threadId, form) } suspend fun end( @@ -486,7 +495,7 @@ object DirectMessagesService { "_csrftoken" to csrfToken, "_uuid" to deviceUuid, ) - return repository.end(threadId, form) + return service.end(threadId, form) } suspend fun fetchPendingInbox(cursor: String?, seqId: Long): DirectInboxResponse { @@ -503,7 +512,7 @@ object DirectMessagesService { if (seqId != 0L) { queryMap["seq_id"] = seqId.toString() } - return repository.fetchPendingInbox(queryMap) + return service.fetchPendingInbox(queryMap) } suspend fun approveRequest( @@ -515,7 +524,7 @@ object DirectMessagesService { "_csrftoken" to csrfToken, "_uuid" to deviceUuid, ) - return repository.approveRequest(threadId, form) + return service.approveRequest(threadId, form) } suspend fun declineRequest( @@ -527,7 +536,7 @@ object DirectMessagesService { "_csrftoken" to csrfToken, "_uuid" to deviceUuid, ) - return repository.declineRequest(threadId, form) + return service.declineRequest(threadId, form) } suspend fun markAsSeen( @@ -545,6 +554,18 @@ object DirectMessagesService { "thread_id" to threadId, "item_id" to itemId, ) - return repository.markItemSeen(threadId, itemId, form) + return service.markItemSeen(threadId, itemId, form) + } + + companion object { + @Volatile + private var INSTANCE: DirectMessagesRepository? = null + + fun getInstance(): DirectMessagesRepository { + return INSTANCE ?: synchronized(this) { + val service: DirectMessagesService = RetrofitFactory.retrofit.create(DirectMessagesService::class.java) + DirectMessagesRepository(service).also { INSTANCE = it } + } + } } } \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/webservices/MediaRepository.kt b/app/src/main/java/awais/instagrabber/webservices/MediaRepository.kt index 172d12cd..c2762b9a 100644 --- a/app/src/main/java/awais/instagrabber/webservices/MediaRepository.kt +++ b/app/src/main/java/awais/instagrabber/webservices/MediaRepository.kt @@ -110,14 +110,17 @@ class MediaRepository(private val service: MediaService) { suspend fun translate( id: String, type: String, // 1 caption 2 comment 3 bio - ): String { + ): String? { val form = mapOf( "id" to id, "type" to type, ) val response = service.translate(form) val jsonObject = JSONObject(response) - return jsonObject.optString("translation") + if (!jsonObject.has("translation") || jsonObject.isNull("translation")) { + return null + } + return jsonObject.getString("translation") } suspend fun uploadFinish( diff --git a/app/src/main/java/awais/instagrabber/webservices/RetrofitFactory.kt b/app/src/main/java/awais/instagrabber/webservices/RetrofitFactory.kt index 876a89b9..a081644b 100644 --- a/app/src/main/java/awais/instagrabber/webservices/RetrofitFactory.kt +++ b/app/src/main/java/awais/instagrabber/webservices/RetrofitFactory.kt @@ -28,7 +28,7 @@ object RetrofitFactory { addInterceptor(AddCookiesInterceptor()) addInterceptor(igErrorsInterceptor) if (BuildConfig.DEBUG) { - // addInterceptor(new LoggingInterceptor()); + // addInterceptor(LoggingInterceptor()) } } val gson = GsonBuilder().apply { diff --git a/app/src/main/res/layout/fragment_profile.xml b/app/src/main/res/layout/fragment_profile.xml index 48b36bfd..0e8613e3 100644 --- a/app/src/main/res/layout/fragment_profile.xml +++ b/app/src/main/res/layout/fragment_profile.xml @@ -5,7 +5,8 @@ android:layout_width="match_parent" android:layout_height="match_parent" android:background="?attr/colorSurface" - app:layoutDescription="@xml/header_list_scene"> + app:layoutDescription="@xml/header_list_scene" + tools:layoutDescription="@xml/profile_fragment_no_acc_layout"> Mentions This Account is Private You won\'t be able to access posts after unfollowing! Are you sure? + Are you sure? You can log in via More -> Account on the bottom-right corner or you can view public accounts without login! This Account has No Posts No Such Posts! diff --git a/app/src/main/res/xml/profile_fragment_no_acc_layout.xml b/app/src/main/res/xml/profile_fragment_no_acc_layout.xml new file mode 100644 index 00000000..5b861dd4 --- /dev/null +++ b/app/src/main/res/xml/profile_fragment_no_acc_layout.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/test/java/awais/instagrabber/common/Adapters.kt b/app/src/test/java/awais/instagrabber/common/Adapters.kt index eac36d61..33b1d034 100644 --- a/app/src/test/java/awais/instagrabber/common/Adapters.kt +++ b/app/src/test/java/awais/instagrabber/common/Adapters.kt @@ -7,6 +7,7 @@ import awais.instagrabber.db.entities.Favorite import awais.instagrabber.models.enums.FavoriteType import awais.instagrabber.repositories.* import awais.instagrabber.repositories.responses.* +import awais.instagrabber.repositories.responses.directmessages.* open class UserServiceAdapter : UserService { override suspend fun getUserInfo(uid: Long): WrappedUser { @@ -166,4 +167,119 @@ open class FavoriteDaoAdapter : FavoriteDao { override suspend fun deleteFavorites(vararg favorites: Favorite) {} override suspend fun deleteAllFavorites() {} +} + +open class DirectMessagesServiceAdapter: DirectMessagesService { + override suspend fun fetchInbox(queryMap: Map): DirectInboxResponse { + TODO("Not yet implemented") + } + + override suspend fun fetchPendingInbox(queryMap: Map): DirectInboxResponse { + TODO("Not yet implemented") + } + + override suspend fun fetchThread(threadId: String, queryMap: Map): DirectThreadFeedResponse { + TODO("Not yet implemented") + } + + override suspend fun fetchUnseenCount(): DirectBadgeCount { + TODO("Not yet implemented") + } + + override suspend fun broadcast(item: String, signedForm: Map): DirectThreadBroadcastResponse { + TODO("Not yet implemented") + } + + override suspend fun addUsers(threadId: String, form: Map): DirectThreadDetailsChangeResponse { + TODO("Not yet implemented") + } + + override suspend fun removeUsers(threadId: String, form: Map): String { + TODO("Not yet implemented") + } + + override suspend fun updateTitle(threadId: String, form: Map): DirectThreadDetailsChangeResponse { + TODO("Not yet implemented") + } + + override suspend fun addAdmins(threadId: String, form: Map): String { + TODO("Not yet implemented") + } + + override suspend fun removeAdmins(threadId: String, form: Map): String { + TODO("Not yet implemented") + } + + override suspend fun deleteItem(threadId: String, itemId: String, form: Map): String { + TODO("Not yet implemented") + } + + override suspend fun rankedRecipients(queryMap: Map): RankedRecipientsResponse { + TODO("Not yet implemented") + } + + override suspend fun forward(form: Map): DirectThreadBroadcastResponse { + TODO("Not yet implemented") + } + + override suspend fun createThread(signedForm: Map): DirectThread { + TODO("Not yet implemented") + } + + override suspend fun mute(threadId: String, form: Map): String { + TODO("Not yet implemented") + } + + override suspend fun unmute(threadId: String, form: Map): String { + TODO("Not yet implemented") + } + + override suspend fun muteMentions(threadId: String, form: Map): String { + TODO("Not yet implemented") + } + + override suspend fun unmuteMentions(threadId: String, form: Map): String { + TODO("Not yet implemented") + } + + override suspend fun participantRequests(threadId: String, pageSize: Int, cursor: String?): DirectThreadParticipantRequestsResponse { + TODO("Not yet implemented") + } + + override suspend fun approveParticipantRequests(threadId: String, form: Map): DirectThreadDetailsChangeResponse { + TODO("Not yet implemented") + } + + override suspend fun declineParticipantRequests(threadId: String, form: Map): DirectThreadDetailsChangeResponse { + TODO("Not yet implemented") + } + + override suspend fun approvalRequired(threadId: String, form: Map): DirectThreadDetailsChangeResponse { + TODO("Not yet implemented") + } + + override suspend fun approvalNotRequired(threadId: String, form: Map): DirectThreadDetailsChangeResponse { + TODO("Not yet implemented") + } + + override suspend fun leave(threadId: String, form: Map): DirectThreadDetailsChangeResponse { + TODO("Not yet implemented") + } + + override suspend fun end(threadId: String, form: Map): DirectThreadDetailsChangeResponse { + TODO("Not yet implemented") + } + + override suspend fun approveRequest(threadId: String, form: Map): String { + TODO("Not yet implemented") + } + + override suspend fun declineRequest(threadId: String, form: Map): String { + TODO("Not yet implemented") + } + + override suspend fun markItemSeen(threadId: String, itemId: String, form: Map): DirectItemSeenResponse { + TODO("Not yet implemented") + } + } \ No newline at end of file diff --git a/app/src/test/java/awais/instagrabber/viewmodels/ProfileFragmentViewModelTest.kt b/app/src/test/java/awais/instagrabber/viewmodels/ProfileFragmentViewModelTest.kt index 471b9c2a..6248a8cf 100644 --- a/app/src/test/java/awais/instagrabber/viewmodels/ProfileFragmentViewModelTest.kt +++ b/app/src/test/java/awais/instagrabber/viewmodels/ProfileFragmentViewModelTest.kt @@ -5,10 +5,8 @@ import androidx.lifecycle.SavedStateHandle import androidx.test.ext.junit.runners.AndroidJUnit4 import awais.instagrabber.MainCoroutineScopeRule import awais.instagrabber.common.* -import awais.instagrabber.db.datasources.AccountDataSource import awais.instagrabber.db.datasources.FavoriteDataSource import awais.instagrabber.db.entities.Favorite -import awais.instagrabber.db.repositories.AccountRepository import awais.instagrabber.db.repositories.FavoriteRepository import awais.instagrabber.getOrAwaitValue import awais.instagrabber.models.HighlightModel @@ -21,6 +19,7 @@ import awais.instagrabber.repositories.responses.User import awais.instagrabber.webservices.* import kotlinx.coroutines.ExperimentalCoroutinesApi import org.json.JSONException +import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.jupiter.api.Assertions.* @@ -37,30 +36,41 @@ internal class ProfileFragmentViewModelTest { @get:Rule val coroutineScope = MainCoroutineScopeRule() - private val testPublicUser = User( - pk = 100, - username = "test", - fullName = "Test user" - ) + private lateinit var testPublicUser: User + private lateinit var testPublicUser1: User - private val testPublicUser1 = User( - pk = 101, - username = "test1", - fullName = "Test1 user1" - ) + private val csrfToken = "csrfToken" + private val deviceUuid = "deviceUuid" + + @Before + fun setup() { + testPublicUser = User( + pk = 100, + username = "test", + fullName = "Test user" + ) + testPublicUser1 = User( + pk = 101, + username = "test1", + fullName = "Test1 user1" + ) + } @ExperimentalCoroutinesApi @Test fun `no state username and null current user`() { val viewModel = ProfileFragmentViewModel( SavedStateHandle(), + null, + deviceUuid, UserRepository(UserServiceAdapter()), FriendshipRepository(FriendshipServiceAdapter()), StoriesRepository(StoriesServiceAdapter()), MediaRepository(MediaServiceAdapter()), GraphQLRepository(GraphQLServiceAdapter()), - AccountRepository(AccountDataSource(AccountDaoAdapter())), FavoriteRepository(FavoriteDataSource(FavoriteDaoAdapter())), + DirectMessagesRepository(DirectMessagesServiceAdapter()), + null, coroutineScope.dispatcher, ) assertEquals(false, viewModel.isLoggedIn.getOrAwaitValue()) @@ -76,13 +86,16 @@ internal class ProfileFragmentViewModelTest { fun `no state username with current user provided`() { val viewModel = ProfileFragmentViewModel( SavedStateHandle(), + csrfToken, + deviceUuid, UserRepository(UserServiceAdapter()), FriendshipRepository(FriendshipServiceAdapter()), StoriesRepository(StoriesServiceAdapter()), MediaRepository(MediaServiceAdapter()), GraphQLRepository(GraphQLServiceAdapter()), - AccountRepository(AccountDataSource(AccountDaoAdapter())), FavoriteRepository(FavoriteDataSource(FavoriteDaoAdapter())), + DirectMessagesRepository(DirectMessagesServiceAdapter()), + null, coroutineScope.dispatcher, ) assertEquals(false, viewModel.isLoggedIn.getOrAwaitValue()) @@ -128,13 +141,16 @@ internal class ProfileFragmentViewModelTest { } val viewModel = ProfileFragmentViewModel( state, + null, + deviceUuid, UserRepository(UserServiceAdapter()), FriendshipRepository(FriendshipServiceAdapter()), StoriesRepository(StoriesServiceAdapter()), MediaRepository(MediaServiceAdapter()), graphQLRepository, - AccountRepository(AccountDataSource(AccountDaoAdapter())), FavoriteRepository(FavoriteDataSource(FavoriteDaoAdapter())), + DirectMessagesRepository(DirectMessagesServiceAdapter()), + null, coroutineScope.dispatcher, ) viewModel.setCurrentUser(Resource.success(null)) @@ -179,13 +195,16 @@ internal class ProfileFragmentViewModelTest { } val viewModel = ProfileFragmentViewModel( state, + csrfToken, + deviceUuid, userRepository, FriendshipRepository(FriendshipServiceAdapter()), StoriesRepository(StoriesServiceAdapter()), MediaRepository(MediaServiceAdapter()), GraphQLRepository(GraphQLServiceAdapter()), - AccountRepository(AccountDataSource(AccountDaoAdapter())), FavoriteRepository(FavoriteDataSource(FavoriteDaoAdapter())), + DirectMessagesRepository(DirectMessagesServiceAdapter()), + null, coroutineScope.dispatcher, ) viewModel.setCurrentUser(Resource.success(User())) @@ -215,13 +234,16 @@ internal class ProfileFragmentViewModelTest { } val viewModel = ProfileFragmentViewModel( state, + null, + deviceUuid, UserRepository(UserServiceAdapter()), FriendshipRepository(FriendshipServiceAdapter()), StoriesRepository(StoriesServiceAdapter()), MediaRepository(MediaServiceAdapter()), graphQLRepository, - AccountRepository(AccountDataSource(AccountDaoAdapter())), FavoriteRepository(FavoriteDataSource(FavoriteDaoAdapter())), + DirectMessagesRepository(DirectMessagesServiceAdapter()), + null, coroutineScope.dispatcher, ) viewModel.setCurrentUser(Resource.success(null)) @@ -267,13 +289,16 @@ internal class ProfileFragmentViewModelTest { })) val viewModel = ProfileFragmentViewModel( state, + null, + deviceUuid, UserRepository(UserServiceAdapter()), FriendshipRepository(FriendshipServiceAdapter()), StoriesRepository(StoriesServiceAdapter()), MediaRepository(MediaServiceAdapter()), graphQLRepository, - AccountRepository(AccountDataSource(AccountDaoAdapter())), favoriteRepository, + DirectMessagesRepository(DirectMessagesServiceAdapter()), + null, coroutineScope.dispatcher, ) viewModel.setCurrentUser(Resource.success(null)) @@ -306,13 +331,16 @@ internal class ProfileFragmentViewModelTest { } val viewModel = ProfileFragmentViewModel( state, + csrfToken, + deviceUuid, userRepository, FriendshipRepository(FriendshipServiceAdapter()), storiesRepository, MediaRepository(MediaServiceAdapter()), GraphQLRepository(GraphQLServiceAdapter()), - AccountRepository(AccountDataSource(AccountDaoAdapter())), FavoriteRepository(FavoriteDataSource(FavoriteDaoAdapter())), + DirectMessagesRepository(DirectMessagesServiceAdapter()), + null, coroutineScope.dispatcher, ) viewModel.setCurrentUser(Resource.success(User())) @@ -332,4 +360,45 @@ internal class ProfileFragmentViewModelTest { } assertEquals(testUserHighlights, userHighlights.data) } + + @ExperimentalCoroutinesApi + @Test + fun `should refresh correctly`() { + val state = SavedStateHandle( + mutableMapOf( + "username" to testPublicUser.username + ) + ) + val graphQLRepository = object : GraphQLRepository(GraphQLServiceAdapter()) { + override suspend fun fetchUser(username: String): User = testPublicUser + } + val viewModel = ProfileFragmentViewModel( + state, + null, + deviceUuid, + UserRepository(UserServiceAdapter()), + FriendshipRepository(FriendshipServiceAdapter()), + StoriesRepository(StoriesServiceAdapter()), + MediaRepository(MediaServiceAdapter()), + graphQLRepository, + FavoriteRepository(FavoriteDataSource(FavoriteDaoAdapter())), + DirectMessagesRepository(DirectMessagesServiceAdapter()), + null, + coroutineScope.dispatcher, + ) + viewModel.setCurrentUser(Resource.success(null)) + assertEquals(false, viewModel.isLoggedIn.getOrAwaitValue()) + var profile = viewModel.profile.getOrAwaitValue() + while (profile.status == Resource.Status.LOADING) { + profile = viewModel.profile.getOrAwaitValue() + } + assertEquals(testPublicUser, profile.data) + testPublicUser = testPublicUser.copy(biography = "new bio") + viewModel.refresh() + profile = viewModel.profile.getOrAwaitValue() + while (profile.status == Resource.Status.LOADING) { + profile = viewModel.profile.getOrAwaitValue() + } + assertEquals(testPublicUser, profile.data) + } } \ No newline at end of file