more collection features

This commit is contained in:
Austin Huang 2021-01-22 23:33:36 -05:00
parent 6aee7ea863
commit 89441a3562
No known key found for this signature in database
GPG Key ID: 84C23AA04587A91F
29 changed files with 517 additions and 58 deletions

View File

@ -22,10 +22,13 @@ public class SavedCollectionsAdapter extends ListAdapter<SavedCollection, TopicC
@Override @Override
public boolean areContentsTheSame(@NonNull final SavedCollection oldItem, @NonNull final SavedCollection newItem) { public boolean areContentsTheSame(@NonNull final SavedCollection oldItem, @NonNull final SavedCollection newItem) {
if (oldItem.getCoverMedias().size() == newItem.getCoverMedias().size()) { if (oldItem.getCoverMedias() != null && newItem.getCoverMedias() != null
if (oldItem.getCoverMedias().size() == 0) return true; && oldItem.getCoverMedias().size() == newItem.getCoverMedias().size()) {
return oldItem.getCoverMedias().get(0).getId().equals(newItem.getCoverMedias().get(0).getId()); return oldItem.getCoverMedias().get(0).getId().equals(newItem.getCoverMedias().get(0).getId());
} }
else if (oldItem.getCoverMedia() != null && newItem.getCoverMedia() != null) {
return oldItem.getCoverMedia().getId().equals(newItem.getCoverMedia().getId());
}
return false; return false;
} }
}; };

View File

@ -127,7 +127,7 @@ public class TopicClusterViewHolder extends RecyclerView.ViewHolder {
// binding.title.setTransitionName("title-" + topicCluster.getId()); // binding.title.setTransitionName("title-" + topicCluster.getId());
binding.cover.setTransitionName("cover-" + topicCluster.getId()); binding.cover.setTransitionName("cover-" + topicCluster.getId());
final String thumbUrl = ResponseBodyUtils.getThumbUrl(topicCluster.getCoverMedias() == null final String thumbUrl = ResponseBodyUtils.getThumbUrl(topicCluster.getCoverMedias() == null
? null ? topicCluster.getCoverMedia()
: topicCluster.getCoverMedias().get(0)); : topicCluster.getCoverMedias().get(0));
if (thumbUrl == null) { if (thumbUrl == null) {
binding.cover.setImageURI((String) null); binding.cover.setImageURI((String) null);

View File

@ -9,7 +9,7 @@ import awais.instagrabber.customviews.helpers.PostFetcher;
import awais.instagrabber.interfaces.FetchListener; import awais.instagrabber.interfaces.FetchListener;
import awais.instagrabber.repositories.responses.Media; import awais.instagrabber.repositories.responses.Media;
import awais.instagrabber.repositories.responses.discover.TopicalExploreFeedResponse; import awais.instagrabber.repositories.responses.discover.TopicalExploreFeedResponse;
import awais.instagrabber.repositories.responses.discover.TopicalExploreItem; import awais.instagrabber.repositories.responses.WrappedMedia;
import awais.instagrabber.webservices.DiscoverService; import awais.instagrabber.webservices.DiscoverService;
import awais.instagrabber.webservices.ServiceCallback; import awais.instagrabber.webservices.ServiceCallback;
@ -35,13 +35,13 @@ public class DiscoverPostFetchService implements PostFetcher.PostFetchService {
} }
moreAvailable = result.isMoreAvailable(); moreAvailable = result.isMoreAvailable();
topicalExploreRequest.setMaxId(result.getNextMaxId()); topicalExploreRequest.setMaxId(result.getNextMaxId());
final List<TopicalExploreItem> items = result.getItems(); final List<WrappedMedia> items = result.getItems();
final List<Media> posts; final List<Media> posts;
if (items == null) { if (items == null) {
posts = Collections.emptyList(); posts = Collections.emptyList();
} else { } else {
posts = items.stream() posts = items.stream()
.map(TopicalExploreItem::getMedia) .map(WrappedMedia::getMedia)
.filter(Objects::nonNull) .filter(Objects::nonNull)
.collect(Collectors.toList()); .collect(Collectors.toList());
} }

View File

@ -10,6 +10,7 @@ import android.graphics.drawable.Drawable;
import android.graphics.drawable.GradientDrawable; import android.graphics.drawable.GradientDrawable;
import android.os.Bundle; import android.os.Bundle;
import android.os.Handler; import android.os.Handler;
import android.util.Log;
import android.view.ActionMode; import android.view.ActionMode;
import android.view.LayoutInflater; import android.view.LayoutInflater;
import android.view.Menu; import android.view.Menu;
@ -17,11 +18,14 @@ import android.view.MenuInflater;
import android.view.MenuItem; import android.view.MenuItem;
import android.view.View; import android.view.View;
import android.view.ViewGroup; import android.view.ViewGroup;
import android.widget.EditText;
import android.widget.Toast;
import androidx.activity.OnBackPressedCallback; import androidx.activity.OnBackPressedCallback;
import androidx.activity.OnBackPressedDispatcher; import androidx.activity.OnBackPressedDispatcher;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.appcompat.app.AlertDialog;
import androidx.coordinatorlayout.widget.CoordinatorLayout; import androidx.coordinatorlayout.widget.CoordinatorLayout;
import androidx.core.content.PermissionChecker; import androidx.core.content.PermissionChecker;
import androidx.core.graphics.ColorUtils; import androidx.core.graphics.ColorUtils;
@ -49,20 +53,23 @@ import awais.instagrabber.asyncs.SavedPostFetchService;
import awais.instagrabber.customviews.PrimaryActionModeCallback; import awais.instagrabber.customviews.PrimaryActionModeCallback;
import awais.instagrabber.databinding.FragmentCollectionPostsBinding; import awais.instagrabber.databinding.FragmentCollectionPostsBinding;
import awais.instagrabber.dialogs.PostsLayoutPreferencesDialogFragment; import awais.instagrabber.dialogs.PostsLayoutPreferencesDialogFragment;
import awais.instagrabber.fragments.CollectionPostsFragmentDirections;
import awais.instagrabber.models.PostsLayoutPreferences; import awais.instagrabber.models.PostsLayoutPreferences;
import awais.instagrabber.models.enums.PostItemType; import awais.instagrabber.models.enums.PostItemType;
import awais.instagrabber.repositories.responses.Media; import awais.instagrabber.repositories.responses.Media;
import awais.instagrabber.repositories.responses.saved.SavedCollection; import awais.instagrabber.repositories.responses.saved.SavedCollection;
import awais.instagrabber.utils.Constants; import awais.instagrabber.utils.Constants;
import awais.instagrabber.utils.CookieUtils;
import awais.instagrabber.utils.DownloadUtils; import awais.instagrabber.utils.DownloadUtils;
import awais.instagrabber.utils.ResponseBodyUtils; import awais.instagrabber.utils.ResponseBodyUtils;
import awais.instagrabber.utils.Utils; import awais.instagrabber.utils.Utils;
import awais.instagrabber.webservices.CollectionService;
import awais.instagrabber.webservices.ServiceCallback;
import static androidx.core.content.PermissionChecker.checkSelfPermission; import static androidx.core.content.PermissionChecker.checkSelfPermission;
import static awais.instagrabber.utils.DownloadUtils.WRITE_PERMISSION; import static awais.instagrabber.utils.DownloadUtils.WRITE_PERMISSION;
public class CollectionPostsFragment extends Fragment implements SwipeRefreshLayout.OnRefreshListener { public class CollectionPostsFragment extends Fragment implements SwipeRefreshLayout.OnRefreshListener {
private static final String TAG = "CollectionPostsFragment";
private static final int STORAGE_PERM_REQUEST_CODE = 8020; private static final int STORAGE_PERM_REQUEST_CODE = 8020;
private static final int STORAGE_PERM_REQUEST_CODE_FOR_SELECTION = 8030; private static final int STORAGE_PERM_REQUEST_CODE_FOR_SELECTION = 8030;
@ -75,6 +82,7 @@ public class CollectionPostsFragment extends Fragment implements SwipeRefreshLay
private Set<Media> selectedFeedModels; private Set<Media> selectedFeedModels;
private Media downloadFeedModel; private Media downloadFeedModel;
private int downloadChildPosition = -1; private int downloadChildPosition = -1;
private CollectionService collectionService;
private PostsLayoutPreferences layoutPreferences = Utils.getPostsLayoutPreferences(Constants.PREF_SAVED_POSTS_LAYOUT); private PostsLayoutPreferences layoutPreferences = Utils.getPostsLayoutPreferences(Constants.PREF_SAVED_POSTS_LAYOUT);
private final OnBackPressedCallback onBackPressedCallback = new OnBackPressedCallback(false) { private final OnBackPressedCallback onBackPressedCallback = new OnBackPressedCallback(false) {
@ -84,7 +92,7 @@ public class CollectionPostsFragment extends Fragment implements SwipeRefreshLay
} }
}; };
private final PrimaryActionModeCallback multiSelectAction = new PrimaryActionModeCallback( private final PrimaryActionModeCallback multiSelectAction = new PrimaryActionModeCallback(
R.menu.multi_select_download_menu, new PrimaryActionModeCallback.CallbacksHelper() { R.menu.saved_collection_select_menu, new PrimaryActionModeCallback.CallbacksHelper() {
@Override @Override
public void onDestroy(final ActionMode mode) { public void onDestroy(final ActionMode mode) {
binding.posts.endSelection(); binding.posts.endSelection();
@ -241,6 +249,11 @@ public class CollectionPostsFragment extends Fragment implements SwipeRefreshLay
setSharedElementEnterTransition(transitionSet); setSharedElementEnterTransition(transitionSet);
postponeEnterTransition(); postponeEnterTransition();
setHasOptionsMenu(true); setHasOptionsMenu(true);
final String cookie = Utils.settingsHelper.getString(Constants.COOKIE);
final long userId = CookieUtils.getUserIdFromCookie(cookie);
final String deviceUuid = Utils.settingsHelper.getString(Constants.DEVICE_UUID);
final String csrfToken = CookieUtils.getCsrfTokenFromCookie(cookie);
collectionService = CollectionService.getInstance(deviceUuid, csrfToken, userId);
} }
@Nullable @Nullable
@ -267,7 +280,7 @@ public class CollectionPostsFragment extends Fragment implements SwipeRefreshLay
@Override @Override
public void onCreateOptionsMenu(@NonNull final Menu menu, @NonNull final MenuInflater inflater) { public void onCreateOptionsMenu(@NonNull final Menu menu, @NonNull final MenuInflater inflater) {
inflater.inflate(R.menu.topic_posts_menu, menu); inflater.inflate(R.menu.collection_posts_menu, menu);
} }
@Override @Override
@ -276,6 +289,58 @@ public class CollectionPostsFragment extends Fragment implements SwipeRefreshLay
showPostsLayoutPreferences(); showPostsLayoutPreferences();
return true; return true;
} }
else if (item.getItemId() == R.id.delete) {
final Context context = getContext();
new AlertDialog.Builder(context)
.setTitle(R.string.edit_collection)
.setMessage(R.string.delete_collection_note)
.setPositiveButton(R.string.confirm, (d, w) -> {
collectionService.deleteCollection(
savedCollection.getId(),
new ServiceCallback<String>() {
@Override
public void onSuccess(final String result) {
SavedCollectionsFragment.pleaseRefresh = true;
NavHostFragment.findNavController(CollectionPostsFragment.this).navigateUp();
}
@Override
public void onFailure(final Throwable t) {
Log.e(TAG, "Error deleting collection", t);
Toast.makeText(context, t.getMessage(), Toast.LENGTH_SHORT).show();
}
});
})
.setNegativeButton(R.string.cancel, null)
.show();
}
else if (item.getItemId() == R.id.edit) {
final Context context = getContext();
final EditText input = new EditText(context);
new AlertDialog.Builder(context)
.setTitle(R.string.edit_collection)
.setView(input)
.setPositiveButton(R.string.confirm, (d, w) -> {
collectionService.editCollectionName(
savedCollection.getId(),
input.getText().toString(),
new ServiceCallback<String>() {
@Override
public void onSuccess(final String result) {
binding.collapsingToolbarLayout.setTitle(input.getText().toString());
SavedCollectionsFragment.pleaseRefresh = true;
}
@Override
public void onFailure(final Throwable t) {
Log.e(TAG, "Error editing collection", t);
Toast.makeText(context, t.getMessage(), Toast.LENGTH_SHORT).show();
}
});
})
.setNegativeButton(R.string.cancel, null)
.show();
}
return super.onOptionsItemSelected(item); return super.onOptionsItemSelected(item);
} }
@ -372,7 +437,7 @@ public class CollectionPostsFragment extends Fragment implements SwipeRefreshLay
private void setupCover() { private void setupCover() {
final String coverUrl = ResponseBodyUtils.getImageUrl(savedCollection.getCoverMedias() == null final String coverUrl = ResponseBodyUtils.getImageUrl(savedCollection.getCoverMedias() == null
? null ? savedCollection.getCoverMedia()
: savedCollection.getCoverMedias().get(0)); : savedCollection.getCoverMedias().get(0));
final DraweeController controller = Fresco final DraweeController controller = Fresco
.newDraweeControllerBuilder() .newDraweeControllerBuilder()

View File

@ -49,7 +49,10 @@ import androidx.core.view.ViewCompat;
import androidx.core.widget.NestedScrollView; import androidx.core.widget.NestedScrollView;
import androidx.fragment.app.DialogFragment; import androidx.fragment.app.DialogFragment;
import androidx.lifecycle.LiveData; import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import androidx.lifecycle.Observer;
import androidx.lifecycle.ViewModelProvider; import androidx.lifecycle.ViewModelProvider;
import androidx.navigation.NavBackStackEntry;
import androidx.navigation.NavController; import androidx.navigation.NavController;
import androidx.navigation.fragment.NavHostFragment; import androidx.navigation.fragment.NavHostFragment;
import androidx.recyclerview.widget.RecyclerView; import androidx.recyclerview.widget.RecyclerView;
@ -130,6 +133,17 @@ public class PostViewV2Fragment extends SharedElementTransitionDialogFragment im
private PopupMenu optionsPopup; private PopupMenu optionsPopup;
private EditTextDialogFragment editTextDialogFragment; private EditTextDialogFragment editTextDialogFragment;
private MutableLiveData<Object> backStackSavedStateResultLiveData;
private final Observer<Object> backStackSavedStateObserver = result -> {
if (result == null) return;
if (result instanceof String) {
final String collection = (String) result;
handleSaveUnsaveResourceLiveData(viewModel.toggleSave(collection, viewModel.getMedia().hasViewerSaved()));
}
// clear result
backStackSavedStateResultLiveData.postValue(null);
};
// private final VerticalDragHelper.OnVerticalDragListener onVerticalDragListener = new VerticalDragHelper.OnVerticalDragListener() { // private final VerticalDragHelper.OnVerticalDragListener onVerticalDragListener = new VerticalDragHelper.OnVerticalDragListener() {
// //
// @Override // @Override
@ -305,6 +319,17 @@ public class PostViewV2Fragment extends SharedElementTransitionDialogFragment im
} }
} }
@Override
public void onResume() {
super.onResume();
final NavController navController = NavHostFragment.findNavController(this);
final NavBackStackEntry backStackEntry = navController.getCurrentBackStackEntry();
if (backStackEntry != null) {
backStackSavedStateResultLiveData = backStackEntry.getSavedStateHandle().getLiveData("collection");
backStackSavedStateResultLiveData.observe(getViewLifecycleOwner(), backStackSavedStateObserver);
}
}
@Override @Override
public void onDestroyView() { public void onDestroyView() {
super.onDestroyView(); super.onDestroyView();
@ -666,7 +691,10 @@ public class PostViewV2Fragment extends SharedElementTransitionDialogFragment im
handleSaveUnsaveResourceLiveData(viewModel.toggleSave()); handleSaveUnsaveResourceLiveData(viewModel.toggleSave());
}); });
binding.save.setOnLongClickListener(v -> { binding.save.setOnLongClickListener(v -> {
Utils.displayToastAboveView(context, v, getString(R.string.save)); final NavController navController = NavHostFragment.findNavController(this);
final Bundle bundle = new Bundle();
bundle.putBoolean("isSaving", true);
navController.navigate(R.id.action_global_savedCollectionsFragment, bundle);
return true; return true;
}); });
} }

View File

@ -17,7 +17,10 @@ import androidx.annotation.Nullable;
import androidx.appcompat.app.AlertDialog; import androidx.appcompat.app.AlertDialog;
import androidx.coordinatorlayout.widget.CoordinatorLayout; import androidx.coordinatorlayout.widget.CoordinatorLayout;
import androidx.fragment.app.Fragment; import androidx.fragment.app.Fragment;
import androidx.lifecycle.SavedStateHandle;
import androidx.lifecycle.ViewModelProvider; import androidx.lifecycle.ViewModelProvider;
import androidx.navigation.NavBackStackEntry;
import androidx.navigation.NavController;
import androidx.navigation.fragment.FragmentNavigator; import androidx.navigation.fragment.FragmentNavigator;
import androidx.navigation.fragment.NavHostFragment; import androidx.navigation.fragment.NavHostFragment;
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
@ -27,7 +30,6 @@ import awais.instagrabber.activities.MainActivity;
import awais.instagrabber.adapters.SavedCollectionsAdapter; import awais.instagrabber.adapters.SavedCollectionsAdapter;
import awais.instagrabber.customviews.helpers.GridSpacingItemDecoration; import awais.instagrabber.customviews.helpers.GridSpacingItemDecoration;
import awais.instagrabber.databinding.FragmentSavedCollectionsBinding; import awais.instagrabber.databinding.FragmentSavedCollectionsBinding;
import awais.instagrabber.repositories.responses.StoryStickerResponse;
import awais.instagrabber.repositories.responses.saved.CollectionsListResponse; import awais.instagrabber.repositories.responses.saved.CollectionsListResponse;
import awais.instagrabber.utils.Constants; import awais.instagrabber.utils.Constants;
import awais.instagrabber.utils.CookieUtils; import awais.instagrabber.utils.CookieUtils;
@ -40,12 +42,14 @@ import static awais.instagrabber.utils.Utils.settingsHelper;
public class SavedCollectionsFragment extends Fragment implements SwipeRefreshLayout.OnRefreshListener { public class SavedCollectionsFragment extends Fragment implements SwipeRefreshLayout.OnRefreshListener {
private static final String TAG = "SavedCollectionsFragment"; private static final String TAG = "SavedCollectionsFragment";
public static boolean pleaseRefresh = false;
private MainActivity fragmentActivity; private MainActivity fragmentActivity;
private CoordinatorLayout root; private CoordinatorLayout root;
private FragmentSavedCollectionsBinding binding; private FragmentSavedCollectionsBinding binding;
private SavedCollectionsViewModel savedCollectionsViewModel; private SavedCollectionsViewModel savedCollectionsViewModel;
private boolean shouldRefresh = true; private boolean shouldRefresh = true;
private boolean isSaving;
private ProfileService profileService; private ProfileService profileService;
@Override @Override
@ -82,6 +86,12 @@ public class SavedCollectionsFragment extends Fragment implements SwipeRefreshLa
inflater.inflate(R.menu.saved_collection_menu, menu); inflater.inflate(R.menu.saved_collection_menu, menu);
} }
@Override
public void onResume() {
super.onResume();
if (pleaseRefresh) onRefresh();
}
@Override @Override
public boolean onOptionsItemSelected(@NonNull final MenuItem item) { public boolean onOptionsItemSelected(@NonNull final MenuItem item) {
if (item.getItemId() == R.id.add) { if (item.getItemId() == R.id.add) {
@ -120,6 +130,8 @@ public class SavedCollectionsFragment extends Fragment implements SwipeRefreshLa
private void init() { private void init() {
setupTopics(); setupTopics();
fetchTopics(null); fetchTopics(null);
final SavedCollectionsFragmentArgs fragmentArgs = SavedCollectionsFragmentArgs.fromBundle(getArguments());
isSaving = fragmentArgs.getIsSaving();
} }
@Override @Override
@ -131,11 +143,18 @@ public class SavedCollectionsFragment extends Fragment implements SwipeRefreshLa
savedCollectionsViewModel = new ViewModelProvider(fragmentActivity).get(SavedCollectionsViewModel.class); savedCollectionsViewModel = new ViewModelProvider(fragmentActivity).get(SavedCollectionsViewModel.class);
binding.topicsRecyclerView.addItemDecoration(new GridSpacingItemDecoration(Utils.convertDpToPx(2))); binding.topicsRecyclerView.addItemDecoration(new GridSpacingItemDecoration(Utils.convertDpToPx(2)));
final SavedCollectionsAdapter adapter = new SavedCollectionsAdapter((topicCluster, root, cover, title, titleColor, backgroundColor) -> { final SavedCollectionsAdapter adapter = new SavedCollectionsAdapter((topicCluster, root, cover, title, titleColor, backgroundColor) -> {
final FragmentNavigator.Extras.Builder builder = new FragmentNavigator.Extras.Builder() final NavController navController = NavHostFragment.findNavController(this);
.addSharedElement(cover, "collection-" + topicCluster.getId()); if (isSaving) {
final SavedCollectionsFragmentDirections.ActionSavedCollectionsFragmentToCollectionPostsFragment action = SavedCollectionsFragmentDirections setNavControllerResult(navController, topicCluster.getId());
.actionSavedCollectionsFragmentToCollectionPostsFragment(topicCluster, titleColor, backgroundColor); navController.navigateUp();
NavHostFragment.findNavController(this).navigate(action, builder.build()); }
else {
final FragmentNavigator.Extras.Builder builder = new FragmentNavigator.Extras.Builder()
.addSharedElement(cover, "collection-" + topicCluster.getId());
final SavedCollectionsFragmentDirections.ActionSavedCollectionsFragmentToCollectionPostsFragment action = SavedCollectionsFragmentDirections
.actionSavedCollectionsFragmentToCollectionPostsFragment(topicCluster, titleColor, backgroundColor);
navController.navigate(action, builder.build());
}
}); });
binding.topicsRecyclerView.setAdapter(adapter); binding.topicsRecyclerView.setAdapter(adapter);
savedCollectionsViewModel.getList().observe(getViewLifecycleOwner(), adapter::submitList); savedCollectionsViewModel.getList().observe(getViewLifecycleOwner(), adapter::submitList);
@ -158,4 +177,11 @@ public class SavedCollectionsFragment extends Fragment implements SwipeRefreshLa
} }
}); });
} }
private void setNavControllerResult(@NonNull final NavController navController, final String result) {
final NavBackStackEntry navBackStackEntry = navController.getPreviousBackStackEntry();
if (navBackStackEntry == null) return;
final SavedStateHandle savedStateHandle = navBackStackEntry.getSavedStateHandle();
savedStateHandle.set("collection", result);
}
} }

View File

@ -946,7 +946,7 @@ public class ProfileFragment extends Fragment implements SwipeRefreshLayout.OnRe
} }
}); });
profileDetailsBinding.btnSaved.setOnClickListener(v -> { profileDetailsBinding.btnSaved.setOnClickListener(v -> {
final NavDirections action = ProfileFragmentDirections.actionGlobalSavedCollectionsFragment(); final NavDirections action = ProfileFragmentDirections.actionGlobalSavedCollectionsFragment(false);
NavHostFragment.findNavController(this).navigate(action); NavHostFragment.findNavController(this).navigate(action);
}); });
profileDetailsBinding.btnLiked.setOnClickListener(v -> { profileDetailsBinding.btnLiked.setOnClickListener(v -> {

View File

@ -0,0 +1,23 @@
package awais.instagrabber.repositories;
import java.util.Map;
import awais.instagrabber.repositories.responses.UserFeedResponse;
import awais.instagrabber.repositories.responses.WrappedFeedResponse;
import awais.instagrabber.repositories.responses.saved.CollectionsListResponse;
import retrofit2.Call;
import retrofit2.http.FieldMap;
import retrofit2.http.FormUrlEncoded;
import retrofit2.http.GET;
import retrofit2.http.POST;
import retrofit2.http.Path;
import retrofit2.http.QueryMap;
public interface CollectionRepository {
@FormUrlEncoded
@POST("/api/v1/collections/{id}/{action}/")
Call<String> changeCollection(@Path("id") String id,
@Path("action") String action,
@FieldMap Map<String, String> signedForm);
}

View File

@ -2,6 +2,7 @@ package awais.instagrabber.repositories;
import java.util.Map; import java.util.Map;
import awais.instagrabber.repositories.responses.WrappedFeedResponse;
import awais.instagrabber.repositories.responses.saved.CollectionsListResponse; import awais.instagrabber.repositories.responses.saved.CollectionsListResponse;
import awais.instagrabber.repositories.responses.UserFeedResponse; import awais.instagrabber.repositories.responses.UserFeedResponse;
import retrofit2.Call; import retrofit2.Call;
@ -18,11 +19,11 @@ public interface ProfileRepository {
Call<UserFeedResponse> fetch(@Path("uid") final long uid, @QueryMap Map<String, String> queryParams); Call<UserFeedResponse> fetch(@Path("uid") final long uid, @QueryMap Map<String, String> queryParams);
@GET("/api/v1/feed/saved/") @GET("/api/v1/feed/saved/")
Call<UserFeedResponse> fetchSaved(@QueryMap Map<String, String> queryParams); Call<WrappedFeedResponse> fetchSaved(@QueryMap Map<String, String> queryParams);
@GET("/api/v1/feed/collection/{collectionId}/") @GET("/api/v1/feed/collection/{collectionId}/")
Call<UserFeedResponse> fetchSavedCollection(@Path("collectionId") final String collectionId, Call<WrappedFeedResponse> fetchSavedCollection(@Path("collectionId") final String collectionId,
@QueryMap Map<String, String> queryParams); @QueryMap Map<String, String> queryParams);
@GET("/api/v1/feed/liked/") @GET("/api/v1/feed/liked/")
Call<UserFeedResponse> fetchLiked(@QueryMap Map<String, String> queryParams); Call<UserFeedResponse> fetchLiked(@QueryMap Map<String, String> queryParams);

View File

@ -0,0 +1,43 @@
package awais.instagrabber.repositories.responses;
import java.util.List;
public class WrappedFeedResponse {
private final int numResults;
private final String nextMaxId;
private final boolean moreAvailable;
private final String status;
private final List<WrappedMedia> items;
public WrappedFeedResponse(final int numResults,
final String nextMaxId,
final boolean moreAvailable,
final String status,
final List<WrappedMedia> items) {
this.numResults = numResults;
this.nextMaxId = nextMaxId;
this.moreAvailable = moreAvailable;
this.status = status;
this.items = items;
}
public int getNumResults() {
return numResults;
}
public String getNextMaxId() {
return nextMaxId;
}
public boolean isMoreAvailable() {
return moreAvailable;
}
public String getStatus() {
return status;
}
public List<WrappedMedia> getItems() {
return items;
}
}

View File

@ -0,0 +1,13 @@
package awais.instagrabber.repositories.responses;
public class WrappedMedia {
private final Media media;
public WrappedMedia(final Media media) {
this.media = media;
}
public Media getMedia() {
return media;
}
}

View File

@ -1,6 +1,7 @@
package awais.instagrabber.repositories.responses.discover; package awais.instagrabber.repositories.responses.discover;
import java.util.List; import java.util.List;
import awais.instagrabber.repositories.responses.WrappedMedia;
public class TopicalExploreFeedResponse { public class TopicalExploreFeedResponse {
private final boolean moreAvailable; private final boolean moreAvailable;
@ -9,7 +10,7 @@ public class TopicalExploreFeedResponse {
private final String status; private final String status;
private final int numResults; private final int numResults;
private final List<TopicCluster> clusters; private final List<TopicCluster> clusters;
private final List<TopicalExploreItem> items; private final List<WrappedMedia> items;
public TopicalExploreFeedResponse(final boolean moreAvailable, public TopicalExploreFeedResponse(final boolean moreAvailable,
final String nextMaxId, final String nextMaxId,
@ -17,7 +18,7 @@ public class TopicalExploreFeedResponse {
final String status, final String status,
final int numResults, final int numResults,
final List<TopicCluster> clusters, final List<TopicCluster> clusters,
final List<TopicalExploreItem> items) { final List<WrappedMedia> items) {
this.moreAvailable = moreAvailable; this.moreAvailable = moreAvailable;
this.nextMaxId = nextMaxId; this.nextMaxId = nextMaxId;
this.maxId = maxId; this.maxId = maxId;
@ -51,7 +52,7 @@ public class TopicalExploreFeedResponse {
return clusters; return clusters;
} }
public List<TopicalExploreItem> getItems() { public List<WrappedMedia> getItems() {
return items; return items;
} }
} }

View File

@ -1,15 +0,0 @@
package awais.instagrabber.repositories.responses.discover;
import awais.instagrabber.repositories.responses.Media;
public class TopicalExploreItem {
private final Media media;
public TopicalExploreItem(final Media media) {
this.media = media;
}
public Media getMedia() {
return media;
}
}

View File

@ -10,17 +10,20 @@ public class SavedCollection implements Serializable {
private final String collectionName; private final String collectionName;
private final String collectionType; private final String collectionType;
private final int collectionMediacount; private final int collectionMediacount;
private final Media coverMedia;
private final List<Media> coverMediaList; private final List<Media> coverMediaList;
public SavedCollection(final String collectionId, public SavedCollection(final String collectionId,
final String collectionName, final String collectionName,
final String collectionType, final String collectionType,
final int collectionMediacount, final int collectionMediacount,
final Media coverMedia,
final List<Media> coverMediaList) { final List<Media> coverMediaList) {
this.collectionId = collectionId; this.collectionId = collectionId;
this.collectionName = collectionName; this.collectionName = collectionName;
this.collectionType = collectionType; this.collectionType = collectionType;
this.collectionMediacount = collectionMediacount; this.collectionMediacount = collectionMediacount;
this.coverMedia = coverMedia;
this.coverMediaList = coverMediaList; this.coverMediaList = coverMediaList;
} }
@ -40,6 +43,11 @@ public class SavedCollection implements Serializable {
return collectionMediacount; return collectionMediacount;
} }
// check the list first, then the single
// i have no idea what condition is required
public Media getCoverMedia() { return coverMedia; }
public List<Media> getCoverMedias() { public List<Media> getCoverMedias() {
return coverMediaList; return coverMediaList;
} }

View File

@ -188,27 +188,33 @@ public class PostViewV2ViewModel extends ViewModel {
@NonNull @NonNull
public LiveData<Resource<Object>> toggleSave() { public LiveData<Resource<Object>> toggleSave() {
if (!media.hasViewerSaved()) { if (!media.hasViewerSaved()) {
return save(); return save(null, false);
} }
return unsave(); return unsave();
} }
public LiveData<Resource<Object>> save() { @NonNull
public LiveData<Resource<Object>> toggleSave(final String collection, final boolean ignoreSaveState) {
return save(collection, ignoreSaveState);
}
public LiveData<Resource<Object>> save(final String collection, final boolean ignoreSaveState) {
final MutableLiveData<Resource<Object>> data = new MutableLiveData<>(); final MutableLiveData<Resource<Object>> data = new MutableLiveData<>();
data.postValue(Resource.loading(null)); data.postValue(Resource.loading(null));
mediaService.save(media.getPk(), getSaveUnsaveCallback(data)); mediaService.save(media.getPk(), collection, getSaveUnsaveCallback(data, ignoreSaveState));
return data; return data;
} }
public LiveData<Resource<Object>> unsave() { public LiveData<Resource<Object>> unsave() {
final MutableLiveData<Resource<Object>> data = new MutableLiveData<>(); final MutableLiveData<Resource<Object>> data = new MutableLiveData<>();
data.postValue(Resource.loading(null)); data.postValue(Resource.loading(null));
mediaService.unsave(media.getPk(), getSaveUnsaveCallback(data)); mediaService.unsave(media.getPk(), getSaveUnsaveCallback(data, false));
return data; return data;
} }
@NonNull @NonNull
private ServiceCallback<Boolean> getSaveUnsaveCallback(final MutableLiveData<Resource<Object>> data) { private ServiceCallback<Boolean> getSaveUnsaveCallback(final MutableLiveData<Resource<Object>> data,
final boolean ignoreSaveState) {
return new ServiceCallback<Boolean>() { return new ServiceCallback<Boolean>() {
@Override @Override
public void onSuccess(final Boolean result) { public void onSuccess(final Boolean result) {
@ -217,7 +223,7 @@ public class PostViewV2ViewModel extends ViewModel {
return; return;
} }
data.postValue(Resource.success(true)); data.postValue(Resource.success(true));
media.setHasViewerSaved(!media.hasViewerSaved()); if (!ignoreSaveState) media.setHasViewerSaved(!media.hasViewerSaved());
saved.postValue(media.hasViewerSaved()); saved.postValue(media.hasViewerSaved());
} }

View File

@ -0,0 +1,120 @@
package awais.instagrabber.webservices;
import androidx.annotation.NonNull;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Collectors;
import awais.instagrabber.repositories.CollectionRepository;
import awais.instagrabber.repositories.responses.Media;
import awais.instagrabber.utils.Utils;
import retrofit2.Call;
import retrofit2.Callback;
import retrofit2.Response;
import retrofit2.Retrofit;
public class CollectionService extends BaseService {
private static final String TAG = "ProfileService";
private final CollectionRepository repository;
private final String deviceUuid, csrfToken;
private final long userId;
private static CollectionService instance;
private CollectionService(final String deviceUuid,
final String csrfToken,
final long userId) {
this.deviceUuid = deviceUuid;
this.csrfToken = csrfToken;
this.userId = userId;
final Retrofit retrofit = getRetrofitBuilder()
.baseUrl("https://i.instagram.com")
.build();
repository = retrofit.create(CollectionRepository.class);
}
public String getCsrfToken() {
return csrfToken;
}
public String getDeviceUuid() {
return deviceUuid;
}
public long getUserId() {
return userId;
}
public static CollectionService getInstance(final String deviceUuid, final String csrfToken, final long userId) {
if (instance == null
|| !Objects.equals(instance.getCsrfToken(), csrfToken)
|| !Objects.equals(instance.getDeviceUuid(), deviceUuid)
|| !Objects.equals(instance.getUserId(), userId)) {
instance = new CollectionService(deviceUuid, csrfToken, userId);
}
return instance;
}
public void addPostsToCollection(final String collectionId,
final List<Media> posts,
final ServiceCallback<String> callback) {
final Map<String, Object> form = new HashMap<>(2);
form.put("module_name", "feed_saved_add_to_collection");
final List<String> ids;
ids = posts.stream()
.map(Media::getPk)
.filter(Objects::nonNull)
.collect(Collectors.toList());
form.put("added_media_ids", "[" + String.join(",", ids) + "]");
changeCollection(collectionId, "edit", form, callback);
}
public void editCollectionName(final String collectionId,
final String name,
final ServiceCallback<String> callback) {
final Map<String, Object> form = new HashMap<>(1);
form.put("name", name);
changeCollection(collectionId, "edit", form, callback);
}
public void deleteCollection(final String collectionId,
final ServiceCallback<String> callback) {
changeCollection(collectionId, "delete", null, callback);
}
public void changeCollection(final String collectionId,
final String action,
final Map<String, Object> options,
final ServiceCallback<String> callback) {
final Map<String, Object> form = new HashMap<>();
form.put("_csrftoken", csrfToken);
form.put("_uuid", deviceUuid);
form.put("_uid", userId);
if (options != null) form.putAll(options);
final Map<String, String> signedForm = Utils.sign(form);
final Call<String> request = repository.changeCollection(collectionId, action, signedForm);
request.enqueue(new Callback<String>() {
@Override
public void onResponse(@NonNull final Call<String> call, @NonNull final Response<String> response) {
if (callback == null) return;
final String collectionsListResponse = response.body();
if (collectionsListResponse == null) {
callback.onSuccess(null);
return;
}
callback.onSuccess(collectionsListResponse);
}
@Override
public void onFailure(@NonNull final Call<String> call, @NonNull final Throwable t) {
if (callback != null) {
callback.onFailure(t);
}
}
});
}
}

View File

@ -102,33 +102,37 @@ public class MediaService extends BaseService {
public void like(final String mediaId, public void like(final String mediaId,
final ServiceCallback<Boolean> callback) { final ServiceCallback<Boolean> callback) {
action(mediaId, "like", callback); action(mediaId, "like", null, callback);
} }
public void unlike(final String mediaId, public void unlike(final String mediaId,
final ServiceCallback<Boolean> callback) { final ServiceCallback<Boolean> callback) {
action(mediaId, "unlike", callback); action(mediaId, "unlike", null, callback);
} }
public void save(final String mediaId, public void save(final String mediaId,
final String collection,
final ServiceCallback<Boolean> callback) { final ServiceCallback<Boolean> callback) {
action(mediaId, "save", callback); action(mediaId, "save", collection, callback);
} }
public void unsave(final String mediaId, public void unsave(final String mediaId,
final ServiceCallback<Boolean> callback) { final ServiceCallback<Boolean> callback) {
action(mediaId, "unsave", callback); action(mediaId, "unsave", null, callback);
} }
private void action(final String mediaId, private void action(final String mediaId,
final String action, final String action,
final String collection,
final ServiceCallback<Boolean> callback) { final ServiceCallback<Boolean> callback) {
final Map<String, Object> form = new HashMap<>(4); final Map<String, Object> form = new HashMap<>();
form.put("media_id", mediaId); form.put("media_id", mediaId);
form.put("_csrftoken", csrfToken); form.put("_csrftoken", csrfToken);
form.put("_uid", userId); form.put("_uid", userId);
form.put("_uuid", deviceUuid); form.put("_uuid", deviceUuid);
// form.put("radio_type", "wifi-none"); // form.put("radio_type", "wifi-none");
if (action.equals("save") && !TextUtils.isEmpty(collection)) form.put("added_collection_ids", "[" + collection + "]");
// there also exists "removed_collection_ids" which can be used with "save" and "unsave"
final Map<String, String> signedForm = Utils.sign(form); final Map<String, String> signedForm = Utils.sign(form);
final Call<String> request = repository.action(action, mediaId, signedForm); final Call<String> request = repository.action(action, mediaId, signedForm);
request.enqueue(new Callback<String>() { request.enqueue(new Callback<String>() {

View File

@ -4,12 +4,19 @@ import androidx.annotation.NonNull;
import com.google.common.collect.ImmutableMap; import com.google.common.collect.ImmutableMap;
import java.util.Collections;
import java.util.HashMap; import java.util.HashMap;
import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects;
import java.util.stream.Collectors;
import awais.instagrabber.repositories.ProfileRepository; import awais.instagrabber.repositories.ProfileRepository;
import awais.instagrabber.repositories.responses.Media;
import awais.instagrabber.repositories.responses.PostsFetchResponse; import awais.instagrabber.repositories.responses.PostsFetchResponse;
import awais.instagrabber.repositories.responses.UserFeedResponse; import awais.instagrabber.repositories.responses.UserFeedResponse;
import awais.instagrabber.repositories.responses.WrappedFeedResponse;
import awais.instagrabber.repositories.responses.WrappedMedia;
import awais.instagrabber.repositories.responses.saved.CollectionsListResponse; import awais.instagrabber.repositories.responses.saved.CollectionsListResponse;
import awais.instagrabber.utils.TextUtils; import awais.instagrabber.utils.TextUtils;
import awais.instagrabber.utils.Utils; import awais.instagrabber.utils.Utils;
@ -76,30 +83,40 @@ public class ProfileService extends BaseService {
final String collectionId, final String collectionId,
final ServiceCallback<PostsFetchResponse> callback) { final ServiceCallback<PostsFetchResponse> callback) {
final ImmutableMap.Builder<String, String> builder = ImmutableMap.builder(); final ImmutableMap.Builder<String, String> builder = ImmutableMap.builder();
Call<UserFeedResponse> request = null; Call<WrappedFeedResponse> request = null;
if (!TextUtils.isEmpty(maxId)) { if (!TextUtils.isEmpty(maxId)) {
builder.put("max_id", maxId); builder.put("max_id", maxId);
} }
if (TextUtils.isEmpty(collectionId) || collectionId.equals("ALL_MEDIA_AUTO_COLLECTION")) request = repository.fetchSaved(builder.build()); if (TextUtils.isEmpty(collectionId) || collectionId.equals("ALL_MEDIA_AUTO_COLLECTION")) request = repository.fetchSaved(builder.build());
else request = repository.fetchSavedCollection(collectionId, builder.build()); else request = repository.fetchSavedCollection(collectionId, builder.build());
request.enqueue(new Callback<UserFeedResponse>() { request.enqueue(new Callback<WrappedFeedResponse>() {
@Override @Override
public void onResponse(@NonNull final Call<UserFeedResponse> call, @NonNull final Response<UserFeedResponse> response) { public void onResponse(@NonNull final Call<WrappedFeedResponse> call, @NonNull final Response<WrappedFeedResponse> response) {
if (callback == null) return; if (callback == null) return;
final UserFeedResponse userFeedResponse = response.body(); final WrappedFeedResponse userFeedResponse = response.body();
if (userFeedResponse == null) { if (userFeedResponse == null) {
callback.onSuccess(null); callback.onSuccess(null);
return; return;
} }
final List<WrappedMedia> items = userFeedResponse.getItems();
final List<Media> posts;
if (items == null) {
posts = Collections.emptyList();
} else {
posts = items.stream()
.map(WrappedMedia::getMedia)
.filter(Objects::nonNull)
.collect(Collectors.toList());
}
callback.onSuccess(new PostsFetchResponse( callback.onSuccess(new PostsFetchResponse(
userFeedResponse.getItems(), posts,
userFeedResponse.isMoreAvailable(), userFeedResponse.isMoreAvailable(),
userFeedResponse.getNextMaxId() userFeedResponse.getNextMaxId()
)); ));
} }
@Override @Override
public void onFailure(@NonNull final Call<UserFeedResponse> call, @NonNull final Throwable t) { public void onFailure(@NonNull final Call<WrappedFeedResponse> call, @NonNull final Throwable t) {
if (callback != null) { if (callback != null) {
callback.onFailure(t); callback.onFailure(t);
} }
@ -171,7 +188,6 @@ public class ProfileService extends BaseService {
}); });
} }
public void fetchLiked(final String maxId, public void fetchLiked(final String maxId,
final ServiceCallback<PostsFetchResponse> callback) { final ServiceCallback<PostsFetchResponse> callback) {
final ImmutableMap.Builder<String, String> builder = ImmutableMap.builder(); final ImmutableMap.Builder<String, String> builder = ImmutableMap.builder();

View File

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/edit"
android:icon="@android:drawable/ic_menu_edit"
android:title="@string/edit_collection"
app:showAsAction="always" />
<item
android:id="@+id/delete"
android:icon="@android:drawable/ic_menu_delete"
android:title="@string/delete_collection"
app:showAsAction="always" />
<item
android:id="@+id/layout"
android:title="@string/layout"
app:showAsAction="never" />
</menu>

View File

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/action_add"
android:icon="@drawable/ic_add"
android:title="@string/add_to_collection"
android:titleCondensed="@string/action_download"
app:showAsAction="always" />
<item
android:id="@+id/action_delete"
android:icon="@drawable/ic_cancel"
android:title="@string/remove_from_collection"
android:titleCondensed="@string/action_download"
app:showAsAction="always" />
<item
android:id="@+id/action_download"
android:icon="@drawable/ic_download"
android:title="@string/action_download"
android:titleCondensed="@string/action_download"
app:showAsAction="always" />
</menu>

View File

@ -76,6 +76,16 @@
app:nullable="false" /> app:nullable="false" />
</action> </action>
<include app:graph="@navigation/saved_nav_graph" />
<action
android:id="@+id/action_global_savedCollectionsFragment"
app:destination="@id/saved_nav_graph">
<argument
android:name="isSaving"
app:argType="boolean" />
</action>
<include app:graph="@navigation/user_search_nav_graph" /> <include app:graph="@navigation/user_search_nav_graph" />
<action <action

View File

@ -70,6 +70,16 @@
app:nullable="false" /> app:nullable="false" />
</action> </action>
<include app:graph="@navigation/saved_nav_graph" />
<action
android:id="@+id/action_global_savedCollectionsFragment"
app:destination="@id/saved_nav_graph">
<argument
android:name="isSaving"
app:argType="boolean" />
</action>
<include app:graph="@navigation/notification_viewer_nav_graph" /> <include app:graph="@navigation/notification_viewer_nav_graph" />
<action <action

View File

@ -70,6 +70,16 @@
app:nullable="false" /> app:nullable="false" />
</action> </action>
<include app:graph="@navigation/saved_nav_graph" />
<action
android:id="@+id/action_global_savedCollectionsFragment"
app:destination="@id/saved_nav_graph">
<argument
android:name="isSaving"
app:argType="boolean" />
</action>
<include app:graph="@navigation/notification_viewer_nav_graph" /> <include app:graph="@navigation/notification_viewer_nav_graph" />
<action <action

View File

@ -38,6 +38,16 @@
app:nullable="false" /> app:nullable="false" />
</action> </action>
<include app:graph="@navigation/saved_nav_graph" />
<action
android:id="@+id/action_global_savedCollectionsFragment"
app:destination="@id/saved_nav_graph">
<argument
android:name="isSaving"
app:argType="boolean" />
</action>
<action <action
android:id="@+id/action_global_profileFragment" android:id="@+id/action_global_profileFragment"
app:destination="@id/profile_nav_graph"> app:destination="@id/profile_nav_graph">

View File

@ -38,6 +38,16 @@
app:nullable="false" /> app:nullable="false" />
</action> </action>
<include app:graph="@navigation/saved_nav_graph" />
<action
android:id="@+id/action_global_savedCollectionsFragment"
app:destination="@id/saved_nav_graph">
<argument
android:name="isSaving"
app:argType="boolean" />
</action>
<action <action
android:id="@+id/action_global_profileFragment" android:id="@+id/action_global_profileFragment"
app:destination="@id/profile_nav_graph"> app:destination="@id/profile_nav_graph">

View File

@ -61,6 +61,16 @@
app:nullable="false" /> app:nullable="false" />
</action> </action>
<include app:graph="@navigation/saved_nav_graph" />
<action
android:id="@+id/action_global_savedCollectionsFragment"
app:destination="@id/saved_nav_graph">
<argument
android:name="isSaving"
app:argType="boolean" />
</action>
<fragment <fragment
android:id="@+id/storyViewerFragment" android:id="@+id/storyViewerFragment"
android:name="awais.instagrabber.fragments.StoryViewerFragment" android:name="awais.instagrabber.fragments.StoryViewerFragment"

View File

@ -86,7 +86,11 @@
<action <action
android:id="@+id/action_global_savedCollectionsFragment" android:id="@+id/action_global_savedCollectionsFragment"
app:destination="@id/saved_nav_graph" /> app:destination="@id/saved_nav_graph">
<argument
android:name="isSaving"
app:argType="boolean" />
</action>
<fragment <fragment
android:id="@+id/profileFragment" android:id="@+id/profileFragment"

View File

@ -78,6 +78,10 @@
android:name="awais.instagrabber.fragments.SavedCollectionsFragment" android:name="awais.instagrabber.fragments.SavedCollectionsFragment"
android:label="@string/saved" android:label="@string/saved"
tools:layout="@layout/fragment_saved_collections" > tools:layout="@layout/fragment_saved_collections" >
<argument
android:name="isSaving"
app:argType="boolean"
android:defaultValue="false" />
<action <action
android:id="@+id/action_savedCollectionsFragment_to_collectionPostsFragment" android:id="@+id/action_savedCollectionsFragment_to_collectionPostsFragment"
app:destination="@id/collectionPostsFragment" /> app:destination="@id/collectionPostsFragment" />

View File

@ -99,6 +99,12 @@
<string name="remove_all_acc_warning">This will remove all added accounts from the app!\nTo remove just one account, long tap the account from the account switcher dialog.\nDo you want to continue?</string> <string name="remove_all_acc_warning">This will remove all added accounts from the app!\nTo remove just one account, long tap the account from the account switcher dialog.\nDo you want to continue?</string>
<string name="time_settings">Date format</string> <string name="time_settings">Date format</string>
<string name="saved_create_collection">Create new collection</string> <string name="saved_create_collection">Create new collection</string>
<string name="edit_collection">Edit collection name</string>
<string name="delete_collection">Delete collection</string>
<string name="delete_collection_confirm">Are you sure you want to delete this collection?</string>
<string name="delete_collection_note">All contained media will remain in other collections.</string>
<string name="add_to_collection">Add to collection...</string>
<string name="remove_from_collection">Remove from collection</string>
<string name="liked">Liked</string> <string name="liked">Liked</string>
<string name="saved">Saved</string> <string name="saved">Saved</string>
<string name="tagged">Tagged</string> <string name="tagged">Tagged</string>