diff --git a/app/src/main/java/awais/instagrabber/repositories/SearchService.kt b/app/src/main/java/awais/instagrabber/repositories/SearchService.kt index 148e8f43..c53aacb1 100644 --- a/app/src/main/java/awais/instagrabber/repositories/SearchService.kt +++ b/app/src/main/java/awais/instagrabber/repositories/SearchService.kt @@ -1,14 +1,15 @@ -package awais.instagrabber.repositories; +package awais.instagrabber.repositories -import java.util.Map; +import awais.instagrabber.repositories.responses.search.SearchResponse +import retrofit2.Call +import retrofit2.http.GET +import retrofit2.http.QueryMap +import retrofit2.http.Url -import awais.instagrabber.repositories.responses.search.SearchResponse; -import retrofit2.Call; -import retrofit2.http.GET; -import retrofit2.http.QueryMap; -import retrofit2.http.Url; - -public interface SearchRepository { +interface SearchService { @GET - Call search(@Url String url, @QueryMap(encoded = true) Map queryParams); -} + suspend fun search( + @Url url: String?, + @QueryMap(encoded = true) queryParams: Map? + ): SearchResponse +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/viewmodels/SearchFragmentViewModel.kt b/app/src/main/java/awais/instagrabber/viewmodels/SearchFragmentViewModel.kt index 45825297..d7ae5908 100644 --- a/app/src/main/java/awais/instagrabber/viewmodels/SearchFragmentViewModel.kt +++ b/app/src/main/java/awais/instagrabber/viewmodels/SearchFragmentViewModel.kt @@ -1,365 +1,284 @@ -package awais.instagrabber.viewmodels; +package awais.instagrabber.viewmodels -import android.app.Application; -import android.util.Log; +import android.app.Application +import android.util.Log +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.Transformations +import androidx.lifecycle.viewModelScope +import awais.instagrabber.db.datasources.RecentSearchDataSource +import awais.instagrabber.db.entities.Favorite +import awais.instagrabber.db.entities.RecentSearch +import awais.instagrabber.db.entities.RecentSearch.Companion.fromSearchItem +import awais.instagrabber.db.repositories.FavoriteRepository +import awais.instagrabber.db.repositories.RecentSearchRepository +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.FavoriteType +import awais.instagrabber.repositories.responses.search.SearchItem +import awais.instagrabber.repositories.responses.search.SearchResponse +import awais.instagrabber.utils.* +import awais.instagrabber.utils.AppExecutors.mainThread +import awais.instagrabber.utils.TextUtils.isEmpty +import awais.instagrabber.webservices.SearchRepository +import com.google.common.collect.ImmutableList +import com.google.common.util.concurrent.FutureCallback +import com.google.common.util.concurrent.Futures +import com.google.common.util.concurrent.SettableFuture +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Dispatchers.IO +import kotlinx.coroutines.launch +import retrofit2.Call +import retrofit2.Callback +import retrofit2.Response +import java.util.* +import java.util.function.BiConsumer +import java.util.stream.Collectors -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.lifecycle.LiveData; -import androidx.lifecycle.MutableLiveData; +class SearchFragmentViewModel(application: Application) : AppStateViewModel(application) { + private val query = MutableLiveData() + private val topResults = MutableLiveData?>>() + private val userResults = MutableLiveData?>>() + private val hashtagResults = MutableLiveData?>>() + private val locationResults = MutableLiveData?>>() + private val searchRepository: SearchRepository by lazy { SearchRepository.getInstance() } + private val searchCallback: Debouncer.Callback = object : Debouncer.Callback { + override fun call(key: String) { + if (tempQuery == null) return + query.postValue(tempQuery) + } -import com.google.common.collect.ImmutableList; -import com.google.common.util.concurrent.FutureCallback; -import com.google.common.util.concurrent.Futures; -import com.google.common.util.concurrent.ListenableFuture; -import com.google.common.util.concurrent.SettableFuture; - -import java.util.Collections; -import java.util.List; -import java.util.Objects; -import java.util.stream.Collectors; - -import awais.instagrabber.db.datasources.RecentSearchDataSource; -import awais.instagrabber.db.entities.Favorite; -import awais.instagrabber.db.entities.RecentSearch; -import awais.instagrabber.db.repositories.FavoriteRepository; -import awais.instagrabber.db.repositories.RecentSearchRepository; -import awais.instagrabber.models.Resource; -import awais.instagrabber.models.enums.FavoriteType; -import awais.instagrabber.repositories.responses.search.SearchItem; -import awais.instagrabber.repositories.responses.search.SearchResponse; -import awais.instagrabber.utils.AppExecutors; -import awais.instagrabber.utils.Constants; -import awais.instagrabber.utils.CookieUtils; -import awais.instagrabber.utils.CoroutineUtilsKt; -import awais.instagrabber.utils.Debouncer; -import awais.instagrabber.utils.TextUtils; -import awais.instagrabber.webservices.SearchService; -import kotlinx.coroutines.Dispatchers; -import retrofit2.Call; -import retrofit2.Callback; -import retrofit2.Response; - -import static androidx.lifecycle.Transformations.distinctUntilChanged; -import static awais.instagrabber.utils.Utils.settingsHelper; - -public class SearchFragmentViewModel extends AppStateViewModel { - private static final String TAG = SearchFragmentViewModel.class.getSimpleName(); - private static final String QUERY = "query"; - - private final MutableLiveData query = new MutableLiveData<>(); - private final MutableLiveData>> topResults = new MutableLiveData<>(); - private final MutableLiveData>> userResults = new MutableLiveData<>(); - private final MutableLiveData>> hashtagResults = new MutableLiveData<>(); - private final MutableLiveData>> locationResults = new MutableLiveData<>(); - - private final SearchService searchService; - private final Debouncer searchDebouncer; - private final boolean isLoggedIn; - private final LiveData distinctQuery; - private final RecentSearchRepository recentSearchRepository; - private final FavoriteRepository favoriteRepository; - - private String tempQuery; - - public SearchFragmentViewModel(@NonNull final Application application) { - super(application); - final String cookie = settingsHelper.getString(Constants.COOKIE); - isLoggedIn = !TextUtils.isEmpty(cookie) && CookieUtils.getUserIdFromCookie(cookie) != 0; - final Debouncer.Callback searchCallback = new Debouncer.Callback() { - @Override - public void call(final String key) { - if (tempQuery == null) return; - query.postValue(tempQuery); - } - - @Override - public void onError(final Throwable t) { - Log.e(TAG, "onError: ", t); - } - }; - searchDebouncer = new Debouncer<>(searchCallback, 500); - distinctQuery = distinctUntilChanged(query); - searchService = SearchService.getInstance(); - recentSearchRepository = RecentSearchRepository.getInstance(RecentSearchDataSource.getInstance(application)); - favoriteRepository = FavoriteRepository.Companion.getInstance(application); + override fun onError(t: Throwable) { + Log.e(TAG, "onError: ", t) + } + } + private val searchDebouncer = Debouncer(searchCallback, 500) + private val cookie = Utils.settingsHelper.getString(Constants.COOKIE) + private val isLoggedIn = !isEmpty(cookie) && getUserIdFromCookie(cookie) != 0L + private val distinctQuery = Transformations.distinctUntilChanged(query) + private val recentSearchRepository: RecentSearchRepository by lazy { + RecentSearchRepository.getInstance(RecentSearchDataSource.getInstance(application)) + } + private val favoriteRepository: FavoriteRepository by lazy { FavoriteRepository.getInstance(application) } + private var tempQuery: String? = null + fun getQuery(): LiveData { + return distinctQuery } - public LiveData getQuery() { - return distinctQuery; + fun getTopResults(): LiveData?>> { + return topResults } - public LiveData>> getTopResults() { - return topResults; + fun getUserResults(): LiveData?>> { + return userResults } - public LiveData>> getUserResults() { - return userResults; + fun getHashtagResults(): LiveData?>> { + return hashtagResults } - public LiveData>> getHashtagResults() { - return hashtagResults; + fun getLocationResults(): LiveData?>> { + return locationResults } - public LiveData>> getLocationResults() { - return locationResults; - } - - public void submitQuery(@Nullable final String query) { - String localQuery = query; + fun submitQuery(query: String?) { + var localQuery = query if (query == null) { - localQuery = ""; + localQuery = "" } - if (tempQuery != null && Objects.equals(localQuery.toLowerCase(), tempQuery.toLowerCase())) return; - tempQuery = query; - if (TextUtils.isEmpty(query)) { + if (tempQuery != null && localQuery!!.lowercase(Locale.getDefault()) == tempQuery!!.lowercase(Locale.getDefault())) return + tempQuery = query + if (isEmpty(query)) { // If empty immediately post it - searchDebouncer.cancel(QUERY); - this.query.postValue(""); - return; + searchDebouncer.cancel(QUERY) + this.query.postValue("") + return } - searchDebouncer.call(QUERY); + searchDebouncer.call(QUERY) } - public void search(@NonNull final String query, - @NonNull final FavoriteType type) { - final MutableLiveData>> liveData = getLiveDataByType(type); - if (liveData == null) return; - if (TextUtils.isEmpty(query)) { - showRecentSearchesAndFavorites(type, liveData); - return; + fun search( + query: String, + type: FavoriteType + ) { + val liveData = getLiveDataByType(type) ?: return + if (isEmpty(query)) { + showRecentSearchesAndFavorites(type, liveData) + return } - if (query.equals("@") || query.equals("#")) return; - final String c; - switch (type) { - case TOP: - c = "blended"; - break; - case USER: - c = "user"; - break; - case HASHTAG: - c = "hashtag"; - break; - case LOCATION: - c = "place"; - break; - default: - return; + if (query == "@" || query == "#") return + val c: String + c = when (type) { + FavoriteType.TOP -> "blended" + FavoriteType.USER -> "user" + FavoriteType.HASHTAG -> "hashtag" + FavoriteType.LOCATION -> "place" + else -> return } - liveData.postValue(Resource.loading(null)); - final Call request = searchService.search(isLoggedIn, query, c); - request.enqueue(new Callback() { - @Override - public void onResponse(@NonNull final Call call, - @NonNull final Response response) { - if (!response.isSuccessful()) { - sendErrorResponse(type); - return; - } - final SearchResponse body = response.body(); - if (body == null) { - sendErrorResponse(type); - return; - } - parseResponse(body, type); + liveData.postValue(loading?>(null)) + viewModelScope.launch(Dispatchers.IO) { + try { + val response = searchRepository.search(isLoggedIn, query, c) + parseResponse(response, type) } - - @Override - public void onFailure(@NonNull final Call call, - @NonNull final Throwable t) { - Log.e(TAG, "onFailure: ", t); + catch (e: Exception) { + sendErrorResponse(type) } - }); + } } - private void showRecentSearchesAndFavorites(@NonNull final FavoriteType type, - @NonNull final MutableLiveData>> liveData) { - final SettableFuture> recentResultsFuture = SettableFuture.create(); - final SettableFuture> favoritesFuture = SettableFuture.create(); - recentSearchRepository.getAllRecentSearches( - CoroutineUtilsKt.getContinuation((recentSearches, throwable) -> AppExecutors.INSTANCE.getMainThread().execute(() -> { - if (throwable != null) { - Log.e(TAG, "showRecentSearchesAndFavorites: ", throwable); - recentResultsFuture.set(Collections.emptyList()); - return; - } - if (type != FavoriteType.TOP) { - recentResultsFuture.set((List) recentSearches - .stream() - .filter(rs -> rs.getType() == type) - .collect(Collectors.toList()) - ); - return; - } - //noinspection unchecked - recentResultsFuture.set((List) recentSearches); - }), Dispatchers.getIO()) - ); - favoriteRepository.getAllFavorites( - CoroutineUtilsKt.getContinuation((favorites, throwable) -> AppExecutors.INSTANCE.getMainThread().execute(() -> { - if (throwable != null) { - favoritesFuture.set(Collections.emptyList()); - Log.e(TAG, "showRecentSearchesAndFavorites: ", throwable); - return; - } - if (type != FavoriteType.TOP) { - favoritesFuture.set((List) favorites - .stream() - .filter(f -> f.getType() == type) - .collect(Collectors.toList()) - ); - return; - } - //noinspection unchecked - favoritesFuture.set((List) favorites); - }), Dispatchers.getIO()) - ); - //noinspection UnstableApiUsage - final ListenableFuture>> listenableFuture = Futures.allAsList(recentResultsFuture, favoritesFuture); - Futures.addCallback(listenableFuture, new FutureCallback>>() { - @Override - public void onSuccess(@Nullable final List> result) { - if (!TextUtils.isEmpty(tempQuery)) return; // Make sure user has not entered anything before updating results + private fun showRecentSearchesAndFavorites( + type: FavoriteType, + liveData: MutableLiveData?>> + ) { + val recentResultsFuture = SettableFuture.create>() + val favoritesFuture = SettableFuture.create>() + viewModelScope.launch(Dispatchers.IO) { + try { + val recentSearches = recentSearchRepository.getAllRecentSearches() + recentResultsFuture.set( + if (type == FavoriteType.TOP) recentSearches + else recentSearches.stream() + .filter { (_, _, _, _, _, type1) -> type1 === type } + .collect(Collectors.toList()) + ) + } + catch (e: Exception) { + recentResultsFuture.set(emptyList()) + } + try { + val favorites = favoriteRepository.getAllFavorites() + favoritesFuture.set( + if (type == FavoriteType.TOP) favorites + else favorites + .stream() + .filter { (_, _, type1) -> type1 === type } + .collect(Collectors.toList()) + ) + } + catch (e: Exception) { + favoritesFuture.set(emptyList()) + } + } + val listenableFuture = Futures.allAsList>(recentResultsFuture, favoritesFuture) + Futures.addCallback(listenableFuture, object : FutureCallback?>?> { + override fun onSuccess(result: List?>?) { + if (!isEmpty(tempQuery)) return // Make sure user has not entered anything before updating results if (result == null) { - liveData.postValue(Resource.success(Collections.emptyList())); - return; + liveData.postValue(success(emptyList())) + return } try { - //noinspection unchecked - liveData.postValue(Resource.success( - ImmutableList.builder() - .addAll(SearchItem.fromRecentSearch((List) result.get(0))) - .addAll(SearchItem.fromFavorite((List) result.get(1))) - .build() - )); - } catch (Exception e) { - Log.e(TAG, "onSuccess: ", e); - liveData.postValue(Resource.success(Collections.emptyList())); + liveData.postValue( + success( + ImmutableList.builder() + .addAll(SearchItem.fromRecentSearch(result[0] as List?)) + .addAll(SearchItem.fromFavorite(result[1] as List?)) + .build() + ) + ) + } catch (e: Exception) { + Log.e(TAG, "onSuccess: ", e) + liveData.postValue(success(emptyList())) } } - @Override - public void onFailure(@NonNull final Throwable t) { - if (!TextUtils.isEmpty(tempQuery)) return; - liveData.postValue(Resource.success(Collections.emptyList())); - Log.e(TAG, "onFailure: ", t); + override fun onFailure(t: Throwable) { + if (!isEmpty(tempQuery)) return + liveData.postValue(success(emptyList())) + Log.e(TAG, "onFailure: ", t) } - }, AppExecutors.INSTANCE.getMainThread()); + }, mainThread) } - private void sendErrorResponse(@NonNull final FavoriteType type) { - final MutableLiveData>> liveData = getLiveDataByType(type); - if (liveData == null) return; - liveData.postValue(Resource.error(null, Collections.emptyList())); + private fun sendErrorResponse(type: FavoriteType) { + val liveData = getLiveDataByType(type) ?: return + liveData.postValue(error(null, emptyList())) } - private MutableLiveData>> getLiveDataByType(@NonNull final FavoriteType type) { - final MutableLiveData>> liveData; - switch (type) { - case TOP: - liveData = topResults; - break; - case USER: - liveData = userResults; - break; - case HASHTAG: - liveData = hashtagResults; - break; - case LOCATION: - liveData = locationResults; - break; - default: - return null; + private fun getLiveDataByType(type: FavoriteType): MutableLiveData?>>? { + val liveData: MutableLiveData?>> + liveData = when (type) { + FavoriteType.TOP -> topResults + FavoriteType.USER -> userResults + FavoriteType.HASHTAG -> hashtagResults + FavoriteType.LOCATION -> locationResults + else -> return null } - return liveData; + return liveData } - private void parseResponse(@NonNull final SearchResponse body, - @NonNull final FavoriteType type) { - final MutableLiveData>> liveData = getLiveDataByType(type); - if (liveData == null) return; + private fun parseResponse( + body: SearchResponse, + type: FavoriteType + ) { + val liveData = getLiveDataByType(type) ?: return if (isLoggedIn) { - if (body.getList() == null) { - liveData.postValue(Resource.success(Collections.emptyList())); - return; + if (body.list == null) { + liveData.postValue(success(emptyList())) + return } - if (type == FavoriteType.HASHTAG || type == FavoriteType.LOCATION) { - liveData.postValue(Resource.success(body.getList() - .stream() - .filter(i -> i.getUser() == null) - .collect(Collectors.toList()))); - return; + if (type === FavoriteType.HASHTAG || type === FavoriteType.LOCATION) { + liveData.postValue(success(body.list + .stream() + .filter { i: SearchItem -> i.user == null } + .collect(Collectors.toList()))) + return } - liveData.postValue(Resource.success(body.getList())); - return; + liveData.postValue(success(body.list)) + return } // anonymous - final List list; - switch (type) { - case TOP: - list = ImmutableList - .builder() - .addAll(body.getUsers() == null ? Collections.emptyList() : body.getUsers()) - .addAll(body.getHashtags() == null ? Collections.emptyList() : body.getHashtags()) - .addAll(body.getPlaces() == null ? Collections.emptyList() : body.getPlaces()) - .build(); - break; - case USER: - list = body.getUsers(); - break; - case HASHTAG: - list = body.getHashtags(); - break; - case LOCATION: - list = body.getPlaces(); - break; - default: - return; + val list: List? + list = when (type) { + FavoriteType.TOP -> ImmutableList + .builder() + .addAll(body.users ?: emptyList()) + .addAll(body.hashtags ?: emptyList()) + .addAll(body.places ?: emptyList()) + .build() + FavoriteType.USER -> body.users + FavoriteType.HASHTAG -> body.hashtags + FavoriteType.LOCATION -> body.places + else -> return } - liveData.postValue(Resource.success(list)); + liveData.postValue(success(list)) } - public void saveToRecentSearches(final SearchItem searchItem) { - if (searchItem == null) return; - try { - final RecentSearch recentSearch = RecentSearch.fromSearchItem(searchItem); - if (recentSearch == null) return; - recentSearchRepository.insertOrUpdateRecentSearch( - recentSearch, - CoroutineUtilsKt.getContinuation((unit, throwable) -> AppExecutors.INSTANCE.getMainThread().execute(() -> { - if (throwable != null) { - Log.e(TAG, "saveToRecentSearches: ", throwable); - // return; - } - // Log.d(TAG, "onSuccess: inserted recent: " + recentSearch); - }), Dispatchers.getIO()) - ); - } catch (Exception e) { - Log.e(TAG, "saveToRecentSearches: ", e); + fun saveToRecentSearches(searchItem: SearchItem?) { + if (searchItem == null) return + viewModelScope.launch(Dispatchers.IO) { + try { + val recentSearch = fromSearchItem(searchItem) + recentSearchRepository.insertOrUpdateRecentSearch(recentSearch!!) + } catch (e: Exception) { + Log.e(TAG, "saveToRecentSearches: ", e) + } } } - @Nullable - public LiveData> deleteRecentSearch(final SearchItem searchItem) { - if (searchItem == null || !searchItem.isRecent()) return null; - final RecentSearch recentSearch = RecentSearch.fromSearchItem(searchItem); - if (recentSearch == null) return null; - final MutableLiveData> data = new MutableLiveData<>(); - data.postValue(Resource.loading(null)); - recentSearchRepository.deleteRecentSearchByIgIdAndType( - recentSearch.getIgId(), - recentSearch.getType(), - CoroutineUtilsKt.getContinuation((unit, throwable) -> AppExecutors.INSTANCE.getMainThread().execute(() -> { - if (throwable != null) { - Log.e(TAG, "deleteRecentSearch: ", throwable); - data.postValue(Resource.error("Error deleting recent item", null)); - return; - } - data.postValue(Resource.success(new Object())); - }), Dispatchers.getIO()) - ); - return data; + fun deleteRecentSearch(searchItem: SearchItem?): LiveData>? { + if (searchItem == null || !searchItem.isRecent) return null + val (_, igId, _, _, _, type) = fromSearchItem(searchItem) ?: return null + val data = MutableLiveData>() + data.postValue(loading(null)) + viewModelScope.launch(Dispatchers.IO) { + try { + recentSearchRepository.deleteRecentSearchByIgIdAndType(igId, type) + data.postValue(success(Any())) + } + catch (e: Exception) { + data.postValue(error(e.message, null)) + } + } + return data } -} + + companion object { + private val TAG = SearchFragmentViewModel::class.java.simpleName + private const val QUERY = "query" + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/webservices/SearchRepository.kt b/app/src/main/java/awais/instagrabber/webservices/SearchRepository.kt new file mode 100644 index 00000000..53be0065 --- /dev/null +++ b/app/src/main/java/awais/instagrabber/webservices/SearchRepository.kt @@ -0,0 +1,38 @@ +package awais.instagrabber.webservices + +import awais.instagrabber.repositories.SearchService +import awais.instagrabber.repositories.responses.search.SearchResponse +import awais.instagrabber.webservices.RetrofitFactory.retrofitWeb +import com.google.common.collect.ImmutableMap +import retrofit2.Call + +class SearchRepository(private val service: SearchService) { + suspend fun search( + isLoggedIn: Boolean, + query: String, + context: String + ): SearchResponse { + val builder = ImmutableMap.builder() + builder.put("query", query) + // context is one of: "blended", "user", "place", "hashtag" + // note that "place" and "hashtag" can contain ONE user result, who knows why + builder.put("context", context) + builder.put("count", "50") + return service.search( + if (isLoggedIn) "https://i.instagram.com/api/v1/fbsearch/topsearch_flat/" else "https://www.instagram.com/web/search/topsearch/", + builder.build() + ) + } + + companion object { + @Volatile + private var INSTANCE: SearchRepository? = null + + fun getInstance(): SearchRepository { + return INSTANCE ?: synchronized(this) { + val service: SearchService = RetrofitFactory.retrofit.create(SearchService::class.java) + SearchRepository(service).also { INSTANCE = it } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/awais/instagrabber/webservices/SearchService.java b/app/src/main/java/awais/instagrabber/webservices/SearchService.java deleted file mode 100644 index 840a8032..00000000 --- a/app/src/main/java/awais/instagrabber/webservices/SearchService.java +++ /dev/null @@ -1,43 +0,0 @@ -package awais.instagrabber.webservices; - -import com.google.common.collect.ImmutableMap; - -import awais.instagrabber.repositories.SearchRepository; -import awais.instagrabber.repositories.responses.search.SearchResponse; -import retrofit2.Call; - -public class SearchService { - private static final String TAG = "LocationService"; - - private final SearchRepository repository; - - private static SearchService instance; - - private SearchService() { - repository = RetrofitFactory.INSTANCE - .getRetrofitWeb() - .create(SearchRepository.class); - } - - public static SearchService getInstance() { - if (instance == null) { - instance = new SearchService(); - } - return instance; - } - - public Call search(final boolean isLoggedIn, - final String query, - final String context) { - final ImmutableMap.Builder builder = ImmutableMap.builder(); - builder.put("query", query); - // context is one of: "blended", "user", "place", "hashtag" - // note that "place" and "hashtag" can contain ONE user result, who knows why - builder.put("context", context); - builder.put("count", "50"); - return repository.search(isLoggedIn - ? "https://i.instagram.com/api/v1/fbsearch/topsearch_flat/" - : "https://www.instagram.com/web/search/topsearch/", - builder.build()); - } -}