1
0
mirror of https://github.com/KokaKiwi/BarInsta synced 2024-11-22 06:37:30 +00:00

convert search-related backend stuff to kotlin

This commit is contained in:
Austin Huang 2021-07-17 21:44:27 -04:00
parent 2dc29031cb
commit 1229992a46
No known key found for this signature in database
GPG Key ID: 84C23AA04587A91F
4 changed files with 282 additions and 367 deletions

View File

@ -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; interface SearchService {
import retrofit2.Call;
import retrofit2.http.GET;
import retrofit2.http.QueryMap;
import retrofit2.http.Url;
public interface SearchRepository {
@GET @GET
Call<SearchResponse> search(@Url String url, @QueryMap(encoded = true) Map<String, String> queryParams); suspend fun search(
@Url url: String?,
@QueryMap(encoded = true) queryParams: Map<String?, String?>?
): SearchResponse
} }

View File

@ -1,365 +1,284 @@
package awais.instagrabber.viewmodels; package awais.instagrabber.viewmodels
import android.app.Application; import android.app.Application
import android.util.Log; 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; class SearchFragmentViewModel(application: Application) : AppStateViewModel(application) {
import androidx.annotation.Nullable; private val query = MutableLiveData<String>()
import androidx.lifecycle.LiveData; private val topResults = MutableLiveData<Resource<List<SearchItem>?>>()
import androidx.lifecycle.MutableLiveData; private val userResults = MutableLiveData<Resource<List<SearchItem>?>>()
private val hashtagResults = MutableLiveData<Resource<List<SearchItem>?>>()
import com.google.common.collect.ImmutableList; private val locationResults = MutableLiveData<Resource<List<SearchItem>?>>()
import com.google.common.util.concurrent.FutureCallback; private val searchRepository: SearchRepository by lazy { SearchRepository.getInstance() }
import com.google.common.util.concurrent.Futures; private val searchCallback: Debouncer.Callback<String> = object : Debouncer.Callback<String> {
import com.google.common.util.concurrent.ListenableFuture; override fun call(key: String) {
import com.google.common.util.concurrent.SettableFuture; if (tempQuery == null) return
query.postValue(tempQuery)
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<String> query = new MutableLiveData<>();
private final MutableLiveData<Resource<List<SearchItem>>> topResults = new MutableLiveData<>();
private final MutableLiveData<Resource<List<SearchItem>>> userResults = new MutableLiveData<>();
private final MutableLiveData<Resource<List<SearchItem>>> hashtagResults = new MutableLiveData<>();
private final MutableLiveData<Resource<List<SearchItem>>> locationResults = new MutableLiveData<>();
private final SearchService searchService;
private final Debouncer<String> searchDebouncer;
private final boolean isLoggedIn;
private final LiveData<String> 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<String> searchCallback = new Debouncer.Callback<String>() {
@Override
public void call(final String key) {
if (tempQuery == null) return;
query.postValue(tempQuery);
} }
@Override override fun onError(t: Throwable) {
public void onError(final Throwable t) { Log.e(TAG, "onError: ", t)
Log.e(TAG, "onError: ", t);
} }
}; }
searchDebouncer = new Debouncer<>(searchCallback, 500); private val searchDebouncer = Debouncer(searchCallback, 500)
distinctQuery = distinctUntilChanged(query); private val cookie = Utils.settingsHelper.getString(Constants.COOKIE)
searchService = SearchService.getInstance(); private val isLoggedIn = !isEmpty(cookie) && getUserIdFromCookie(cookie) != 0L
recentSearchRepository = RecentSearchRepository.getInstance(RecentSearchDataSource.getInstance(application)); private val distinctQuery = Transformations.distinctUntilChanged(query)
favoriteRepository = FavoriteRepository.Companion.getInstance(application); 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<String> {
return distinctQuery
} }
public LiveData<String> getQuery() { fun getTopResults(): LiveData<Resource<List<SearchItem>?>> {
return distinctQuery; return topResults
} }
public LiveData<Resource<List<SearchItem>>> getTopResults() { fun getUserResults(): LiveData<Resource<List<SearchItem>?>> {
return topResults; return userResults
} }
public LiveData<Resource<List<SearchItem>>> getUserResults() { fun getHashtagResults(): LiveData<Resource<List<SearchItem>?>> {
return userResults; return hashtagResults
} }
public LiveData<Resource<List<SearchItem>>> getHashtagResults() { fun getLocationResults(): LiveData<Resource<List<SearchItem>?>> {
return hashtagResults; return locationResults
} }
public LiveData<Resource<List<SearchItem>>> getLocationResults() { fun submitQuery(query: String?) {
return locationResults; var localQuery = query
}
public void submitQuery(@Nullable final String query) {
String localQuery = query;
if (query == null) { if (query == null) {
localQuery = ""; localQuery = ""
} }
if (tempQuery != null && Objects.equals(localQuery.toLowerCase(), tempQuery.toLowerCase())) return; if (tempQuery != null && localQuery!!.lowercase(Locale.getDefault()) == tempQuery!!.lowercase(Locale.getDefault())) return
tempQuery = query; tempQuery = query
if (TextUtils.isEmpty(query)) { if (isEmpty(query)) {
// If empty immediately post it // If empty immediately post it
searchDebouncer.cancel(QUERY); searchDebouncer.cancel(QUERY)
this.query.postValue(""); this.query.postValue("")
return; return
} }
searchDebouncer.call(QUERY); searchDebouncer.call(QUERY)
} }
public void search(@NonNull final String query, fun search(
@NonNull final FavoriteType type) { query: String,
final MutableLiveData<Resource<List<SearchItem>>> liveData = getLiveDataByType(type); type: FavoriteType
if (liveData == null) return; ) {
if (TextUtils.isEmpty(query)) { val liveData = getLiveDataByType(type) ?: return
showRecentSearchesAndFavorites(type, liveData); if (isEmpty(query)) {
return; showRecentSearchesAndFavorites(type, liveData)
return
} }
if (query.equals("@") || query.equals("#")) return; if (query == "@" || query == "#") return
final String c; val c: String
switch (type) { c = when (type) {
case TOP: FavoriteType.TOP -> "blended"
c = "blended"; FavoriteType.USER -> "user"
break; FavoriteType.HASHTAG -> "hashtag"
case USER: FavoriteType.LOCATION -> "place"
c = "user"; else -> return
break;
case HASHTAG:
c = "hashtag";
break;
case LOCATION:
c = "place";
break;
default:
return;
} }
liveData.postValue(Resource.loading(null)); liveData.postValue(loading<List<SearchItem>?>(null))
final Call<SearchResponse> request = searchService.search(isLoggedIn, query, c); viewModelScope.launch(Dispatchers.IO) {
request.enqueue(new Callback<SearchResponse>() { try {
@Override val response = searchRepository.search(isLoggedIn, query, c)
public void onResponse(@NonNull final Call<SearchResponse> call, parseResponse(response, type)
@NonNull final Response<SearchResponse> response) { }
if (!response.isSuccessful()) { catch (e: Exception) {
sendErrorResponse(type); sendErrorResponse(type)
return;
} }
final SearchResponse body = response.body();
if (body == null) {
sendErrorResponse(type);
return;
} }
parseResponse(body, type);
} }
@Override private fun showRecentSearchesAndFavorites(
public void onFailure(@NonNull final Call<SearchResponse> call, type: FavoriteType,
@NonNull final Throwable t) { liveData: MutableLiveData<Resource<List<SearchItem>?>>
Log.e(TAG, "onFailure: ", t); ) {
} val recentResultsFuture = SettableFuture.create<List<RecentSearch>>()
}); val favoritesFuture = SettableFuture.create<List<Favorite>>()
} viewModelScope.launch(Dispatchers.IO) {
try {
private void showRecentSearchesAndFavorites(@NonNull final FavoriteType type, val recentSearches = recentSearchRepository.getAllRecentSearches()
@NonNull final MutableLiveData<Resource<List<SearchItem>>> liveData) { recentResultsFuture.set(
final SettableFuture<List<RecentSearch>> recentResultsFuture = SettableFuture.create(); if (type == FavoriteType.TOP) recentSearches
final SettableFuture<List<Favorite>> favoritesFuture = SettableFuture.create(); else recentSearches.stream()
recentSearchRepository.getAllRecentSearches( .filter { (_, _, _, _, _, type1) -> type1 === type }
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<RecentSearch>) recentSearches
.stream()
.filter(rs -> rs.getType() == type)
.collect(Collectors.toList()) .collect(Collectors.toList())
); )
return;
} }
//noinspection unchecked catch (e: Exception) {
recentResultsFuture.set((List<RecentSearch>) recentSearches); recentResultsFuture.set(emptyList())
}), 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<Favorite>) favorites
.stream()
.filter(f -> f.getType() == type)
.collect(Collectors.toList())
);
return;
}
//noinspection unchecked
favoritesFuture.set((List<Favorite>) favorites);
}), Dispatchers.getIO())
);
//noinspection UnstableApiUsage
final ListenableFuture<List<List<?>>> listenableFuture = Futures.allAsList(recentResultsFuture, favoritesFuture);
Futures.addCallback(listenableFuture, new FutureCallback<List<List<?>>>() {
@Override
public void onSuccess(@Nullable final List<List<?>> result) {
if (!TextUtils.isEmpty(tempQuery)) return; // Make sure user has not entered anything before updating results
if (result == null) {
liveData.postValue(Resource.success(Collections.emptyList()));
return;
} }
try { try {
//noinspection unchecked val favorites = favoriteRepository.getAllFavorites()
liveData.postValue(Resource.success( favoritesFuture.set(
ImmutableList.<SearchItem>builder() if (type == FavoriteType.TOP) favorites
.addAll(SearchItem.fromRecentSearch((List<RecentSearch>) result.get(0))) else favorites
.addAll(SearchItem.fromFavorite((List<Favorite>) result.get(1)))
.build()
));
} catch (Exception e) {
Log.e(TAG, "onSuccess: ", e);
liveData.postValue(Resource.success(Collections.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);
}
}, AppExecutors.INSTANCE.getMainThread());
}
private void sendErrorResponse(@NonNull final FavoriteType type) {
final MutableLiveData<Resource<List<SearchItem>>> liveData = getLiveDataByType(type);
if (liveData == null) return;
liveData.postValue(Resource.error(null, Collections.emptyList()));
}
private MutableLiveData<Resource<List<SearchItem>>> getLiveDataByType(@NonNull final FavoriteType type) {
final MutableLiveData<Resource<List<SearchItem>>> 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;
}
return liveData;
}
private void parseResponse(@NonNull final SearchResponse body,
@NonNull final FavoriteType type) {
final MutableLiveData<Resource<List<SearchItem>>> liveData = getLiveDataByType(type);
if (liveData == null) return;
if (isLoggedIn) {
if (body.getList() == null) {
liveData.postValue(Resource.success(Collections.emptyList()));
return;
}
if (type == FavoriteType.HASHTAG || type == FavoriteType.LOCATION) {
liveData.postValue(Resource.success(body.getList()
.stream() .stream()
.filter(i -> i.getUser() == null) .filter { (_, _, type1) -> type1 === type }
.collect(Collectors.toList()))); .collect(Collectors.toList())
return; )
} }
liveData.postValue(Resource.success(body.getList())); catch (e: Exception) {
return; favoritesFuture.set(emptyList())
}
}
val listenableFuture = Futures.allAsList<List<*>>(recentResultsFuture, favoritesFuture)
Futures.addCallback(listenableFuture, object : FutureCallback<List<List<*>?>?> {
override fun onSuccess(result: List<List<*>?>?) {
if (!isEmpty(tempQuery)) return // Make sure user has not entered anything before updating results
if (result == null) {
liveData.postValue(success(emptyList()))
return
}
try {
liveData.postValue(
success(
ImmutableList.builder<SearchItem>()
.addAll(SearchItem.fromRecentSearch(result[0] as List<RecentSearch?>?))
.addAll(SearchItem.fromFavorite(result[1] as List<Favorite?>?))
.build()
)
)
} catch (e: Exception) {
Log.e(TAG, "onSuccess: ", e)
liveData.postValue(success(emptyList()))
}
}
override fun onFailure(t: Throwable) {
if (!isEmpty(tempQuery)) return
liveData.postValue(success(emptyList()))
Log.e(TAG, "onFailure: ", t)
}
}, mainThread)
}
private fun sendErrorResponse(type: FavoriteType) {
val liveData = getLiveDataByType(type) ?: return
liveData.postValue(error(null, emptyList()))
}
private fun getLiveDataByType(type: FavoriteType): MutableLiveData<Resource<List<SearchItem>?>>? {
val liveData: MutableLiveData<Resource<List<SearchItem>?>>
liveData = when (type) {
FavoriteType.TOP -> topResults
FavoriteType.USER -> userResults
FavoriteType.HASHTAG -> hashtagResults
FavoriteType.LOCATION -> locationResults
else -> return null
}
return liveData
}
private fun parseResponse(
body: SearchResponse,
type: FavoriteType
) {
val liveData = getLiveDataByType(type) ?: return
if (isLoggedIn) {
if (body.list == null) {
liveData.postValue(success(emptyList()))
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(success(body.list))
return
} }
// anonymous // anonymous
final List<SearchItem> list; val list: List<SearchItem>?
switch (type) { list = when (type) {
case TOP: FavoriteType.TOP -> ImmutableList
list = ImmutableList .builder<SearchItem>()
.<SearchItem>builder() .addAll(body.users ?: emptyList())
.addAll(body.getUsers() == null ? Collections.emptyList() : body.getUsers()) .addAll(body.hashtags ?: emptyList())
.addAll(body.getHashtags() == null ? Collections.emptyList() : body.getHashtags()) .addAll(body.places ?: emptyList())
.addAll(body.getPlaces() == null ? Collections.emptyList() : body.getPlaces()) .build()
.build(); FavoriteType.USER -> body.users
break; FavoriteType.HASHTAG -> body.hashtags
case USER: FavoriteType.LOCATION -> body.places
list = body.getUsers(); else -> return
break;
case HASHTAG:
list = body.getHashtags();
break;
case LOCATION:
list = body.getPlaces();
break;
default:
return;
} }
liveData.postValue(Resource.success(list)); liveData.postValue(success(list))
} }
public void saveToRecentSearches(final SearchItem searchItem) { fun saveToRecentSearches(searchItem: SearchItem?) {
if (searchItem == null) return; if (searchItem == null) return
viewModelScope.launch(Dispatchers.IO) {
try { try {
final RecentSearch recentSearch = RecentSearch.fromSearchItem(searchItem); val recentSearch = fromSearchItem(searchItem)
if (recentSearch == null) return; recentSearchRepository.insertOrUpdateRecentSearch(recentSearch!!)
recentSearchRepository.insertOrUpdateRecentSearch( } catch (e: Exception) {
recentSearch, Log.e(TAG, "saveToRecentSearches: ", e)
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);
} }
} }
@Nullable fun deleteRecentSearch(searchItem: SearchItem?): LiveData<Resource<Any?>>? {
public LiveData<Resource<Object>> deleteRecentSearch(final SearchItem searchItem) { if (searchItem == null || !searchItem.isRecent) return null
if (searchItem == null || !searchItem.isRecent()) return null; val (_, igId, _, _, _, type) = fromSearchItem(searchItem) ?: return null
final RecentSearch recentSearch = RecentSearch.fromSearchItem(searchItem); val data = MutableLiveData<Resource<Any?>>()
if (recentSearch == null) return null; data.postValue(loading(null))
final MutableLiveData<Resource<Object>> data = new MutableLiveData<>(); viewModelScope.launch(Dispatchers.IO) {
data.postValue(Resource.loading(null)); try {
recentSearchRepository.deleteRecentSearchByIgIdAndType( recentSearchRepository.deleteRecentSearchByIgIdAndType(igId, type)
recentSearch.getIgId(), data.postValue(success(Any()))
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())); catch (e: Exception) {
}), Dispatchers.getIO()) data.postValue(error(e.message, null))
); }
return data; }
return data
}
companion object {
private val TAG = SearchFragmentViewModel::class.java.simpleName
private const val QUERY = "query"
} }
} }

View File

@ -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<String, String>()
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 }
}
}
}
}

View File

@ -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<SearchResponse> search(final boolean isLoggedIn,
final String query,
final String context) {
final ImmutableMap.Builder<String, String> 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());
}
}