commit 3f6b305219492b01af706e58faf00679164ebb6b from: darkwolf date: Sun Jul 19 00:50:51 2026 UTC AccountSession in Kotlin commit - a4f512fa7a99d7d5e510ea2cec0efa0d721d42a1 commit + 3f6b305219492b01af706e58faf00679164ebb6b blob - 3aebcd1ad8674971d61d33d43f0dc8792596edc6 (mode 644) blob + /dev/null --- mastodon/src/main/java/org/joinmastodon/android/api/session/AccountSession.java +++ /dev/null @@ -1,377 +0,0 @@ -package org.joinmastodon.android.api.session; - -import android.app.Activity; -import android.content.Context; -import android.content.SharedPreferences; -import android.net.Uri; -import android.text.TextUtils; -import android.util.Log; - -import org.joinmastodon.android.E; -import org.joinmastodon.android.MastodonApp; -import org.joinmastodon.android.R; -import org.joinmastodon.android.api.CacheController; -import org.joinmastodon.android.api.MastodonAPIController; -import org.joinmastodon.android.api.PushSubscriptionManager; -import org.joinmastodon.android.api.StatusInteractionController; -import org.joinmastodon.android.api.requests.accounts.GetPreferences; -import org.joinmastodon.android.api.requests.accounts.UpdateAccountCredentialsPreferences; -import org.joinmastodon.android.api.requests.markers.GetMarkers; -import org.joinmastodon.android.api.requests.markers.SaveMarkers; -import org.joinmastodon.android.api.requests.oauth.RevokeOauthToken; -import org.joinmastodon.android.events.NotificationsMarkerUpdatedEvent; -import org.joinmastodon.android.model.Account; -import org.joinmastodon.android.model.AltTextFilter; -import org.joinmastodon.android.model.Application; -import org.joinmastodon.android.model.FilterAction; -import org.joinmastodon.android.model.FilterContext; -import org.joinmastodon.android.model.FilterResult; -import org.joinmastodon.android.model.Instance; -import org.joinmastodon.android.model.FollowList; -import org.joinmastodon.android.model.LegacyFilter; -import org.joinmastodon.android.model.Preferences; -import org.joinmastodon.android.model.PushSubscription; -import org.joinmastodon.android.model.Status; -import org.joinmastodon.android.model.TimelineMarkers; -import org.joinmastodon.android.model.Token; -import org.joinmastodon.android.utils.ObjectIdComparator; - -import java.io.File; -import java.util.ArrayList; -import java.util.EnumSet; -import java.util.List; -import java.util.Objects; -import java.util.Optional; -import java.util.function.Consumer; -import java.util.function.Function; - -import me.grishka.appkit.api.Callback; -import me.grishka.appkit.api.ErrorResponse; - -public class AccountSession{ - private static final String TAG="AccountSession"; - - public Token token; - public Account self; - public String domain; - public Application app; - public long infoLastUpdated; - public boolean activated=true; - public String pushPrivateKey; - public String pushPublicKey; - public String pushAuthKey; - public PushSubscription pushSubscription; - public boolean needUpdatePushSettings; - public long filtersLastUpdated; - public List wordFilters=new ArrayList<>(); - public String pushAccountID; - public AccountActivationInfo activationInfo; - public Preferences preferences; - private transient MastodonAPIController apiController; - private transient StatusInteractionController statusInteractionController, remoteStatusInteractionController; - private transient CacheController cacheController; - private transient PushSubscriptionManager pushSubscriptionManager; - private transient SharedPreferences prefs; - private transient boolean preferencesNeedSaving; - private transient AccountLocalPreferences localPreferences; - private transient List lists; - - AccountSession(Token token, Account self, Application app, String domain, boolean activated, AccountActivationInfo activationInfo){ - this.token=token; - this.self=self; - this.domain=domain; - this.app=app; - this.activated=activated; - this.activationInfo=activationInfo; - infoLastUpdated=System.currentTimeMillis(); - } - - AccountSession(){} - - public String getID(){ - return domain+"_"+self.id; - } - - public MastodonAPIController getApiController(){ - if(apiController==null) - apiController=new MastodonAPIController(this); - return apiController; - } - - public StatusInteractionController getStatusInteractionController(){ - if(statusInteractionController==null) - statusInteractionController=new StatusInteractionController(getID()); - return statusInteractionController; - } - - public StatusInteractionController getRemoteStatusInteractionController(){ - if(remoteStatusInteractionController==null) - remoteStatusInteractionController=new StatusInteractionController(getID(), false); - return remoteStatusInteractionController; - } - - public CacheController getCacheController(){ - if(cacheController==null) - cacheController=new CacheController(getID()); - return cacheController; - } - - public PushSubscriptionManager getPushSubscriptionManager(){ - if(pushSubscriptionManager==null) - pushSubscriptionManager=new PushSubscriptionManager(getID()); - return pushSubscriptionManager; - } - - public String getFullUsername(){ - return '@'+self.username+'@'+domain; - } - - public void preferencesFromAccountSource(Account account) { - if (account != null && account.source != null && preferences != null) { - if (account.source.privacy != null) - preferences.postingDefaultVisibility = account.source.privacy; - if (account.source.language != null) - preferences.postingDefaultLanguage = account.source.language; - } - } - - public void reloadPreferences(Consumer callback){ - new GetPreferences() - .setCallback(new Callback<>(){ - @Override - public void onSuccess(Preferences result){ - preferences=result; - preferencesFromAccountSource(self); - if(callback!=null) - callback.accept(result); - AccountSessionManager.getInstance().writeAccountsFile(); - } - - @Override - public void onError(ErrorResponse error){ - Log.w(TAG, "Failed to load preferences for account "+getID()+": "+error); - if (preferences==null) - preferences=new Preferences(); - preferencesFromAccountSource(self); - } - }) - .exec(getID()); - } - - public SharedPreferences getRawLocalPreferences(){ - if(prefs==null) - prefs=MastodonApp.context.getSharedPreferences(getID(), Context.MODE_PRIVATE); - return prefs; - } - - public void reloadNotificationsMarker(Consumer callback){ - new GetMarkers() - .setCallback(new Callback<>(){ - @Override - public void onSuccess(TimelineMarkers result){ - if(result.notifications!=null && !TextUtils.isEmpty(result.notifications.lastReadId)){ - String id=result.notifications.lastReadId; - String lastKnown=getLastKnownNotificationsMarker(); - if(ObjectIdComparator.INSTANCE.compare(id, lastKnown)<0){ - // Marker moved back -- previous marker update must have failed. - // Pretend it didn't happen and repeat the request. - id=lastKnown; - new SaveMarkers(null, id).exec(getID()); - } - callback.accept(id); - setNotificationsMarker(id, false); - } - } - - @Override - public void onError(ErrorResponse error){} - }) - .exec(getID()); - } - - public String getLastKnownNotificationsMarker(){ - return getRawLocalPreferences().getString("notificationsMarker", null); - } - - public void setNotificationsMarker(String id, boolean clearUnread){ - getRawLocalPreferences().edit().putString("notificationsMarker", id).apply(); - E.post(new NotificationsMarkerUpdatedEvent(getID(), id, clearUnread)); - } - - public void logOut(Activity activity, Runnable onDone){ - new RevokeOauthToken(app.clientId, app.clientSecret, token.accessToken) - .setCallback(new Callback<>(){ - @Override - public void onSuccess(Object result){ - AccountSessionManager.getInstance().removeAccount(getID()); - onDone.run(); - } - - @Override - public void onError(ErrorResponse error){ - AccountSessionManager.getInstance().removeAccount(getID()); - onDone.run(); - } - }) - .wrapProgress(activity, R.string.loading, false) - .exec(getID()); - } - - public void savePreferencesLater(){ - preferencesNeedSaving=true; - } - - public void savePreferencesIfPending(){ - if(preferencesNeedSaving){ - new UpdateAccountCredentialsPreferences(preferences, self.locked, self.discoverable, self.source.indexable) - .setCallback(new Callback<>(){ - @Override - public void onSuccess(Account result){ - preferencesNeedSaving=false; - self=result; - AccountSessionManager.getInstance().writeAccountsFile(); - } - - @Override - public void onError(ErrorResponse error){ - Log.e(TAG, "failed to save preferences: "+error); - } - }) - .exec(getID()); - } - } - - public AccountLocalPreferences getLocalPreferences(){ - if(localPreferences==null) - localPreferences=new AccountLocalPreferences(getRawLocalPreferences(), this); - return localPreferences; - } - - public void filterStatuses(List statuses, FilterContext context){ - filterStatuses(statuses, context, null); - } - - public void filterStatuses(List statuses, FilterContext context, Account profile){ - filterStatusContainingObjects(statuses, Function.identity(), context, profile); - } - - public void filterStatusContainingObjects(List objects, Function extractor, FilterContext context){ - filterStatusContainingObjects(objects, extractor, context, null); - } - - private boolean statusIsOnOwnProfile(Status s, Account profile){ - return self != null && profile != null && s.account != null - && Objects.equals(self.id, profile.id) && Objects.equals(self.id, s.account.id); - } - - private boolean isFilteredType(Status s){ - AccountLocalPreferences localPreferences = getLocalPreferences(); - return (!localPreferences.showReplies && s.inReplyToId != null) - || (!localPreferences.showBoosts && s.reblog != null); - } - - public void filterStatusContainingObjects(List objects, Function extractor, FilterContext context, Account profile){ - AccountLocalPreferences localPreferences = getLocalPreferences(); - if(!localPreferences.serverSideFiltersSupported) for(T obj:objects){ - Status s=extractor.apply(obj); - if(s!=null && s.filtered!=null){ - localPreferences.serverSideFiltersSupported=true; - localPreferences.save(); - break; - } - } - - List removeUs=new ArrayList<>(); - for(int i=0; i0){ - // oops, we're about to remove an item that has a gap after... - // gotta find the previous status that's not also about to be removed - for(int j=i-1; j>=0; j--){ - T p=objects.get(j); - Status prev=extractor.apply(objects.get(j)); - if(prev!=null && !removeUs.contains(p)){ - prev.hasGapAfter=s.hasGapAfter; - break; - } - } - } - } - } - objects.removeAll(removeUs); - } - - public boolean filterStatusContainingObject(T object, Function extractor, FilterContext context, Account profile){ - Status s=extractor.apply(object); - if(s==null) - return false; - // don't hide own posts in own profile - if(statusIsOnOwnProfile(s, profile)) - return false; - if(isFilteredType(s) && (context == FilterContext.HOME || context == FilterContext.PUBLIC)) - return true; - // Even with server-side filters, clients are expected to remove statuses that match a filter that hides them - if(getLocalPreferences().serverSideFiltersSupported){ - // Moshidon: this code path in CustomLocalTimelines makes the app crash, so this check is here - if (s.filtered == null) - return false; - for(FilterResult filter : s.filtered){ - if(filter.filter.isActive() && filter.filter.filterAction==FilterAction.HIDE && filter.filter.context.contains(context)) - return true; - } - }else if(wordFilters!=null){ - for(LegacyFilter filter : wordFilters){ - if(filter.context.contains(context) && filter.matches(s) && filter.isActive()) - return true; - } - } - return false; - } - - public List getClientSideFilters(Status status) { - List filters = new ArrayList<>(); - - // filter post that have no alt text - // it only applies when activated in the settings - AltTextFilter altTextFilter=new AltTextFilter(FilterAction.WARN, EnumSet.allOf(FilterContext.class)); - if(altTextFilter.matches(status)){ - FilterResult filterResult=new FilterResult(); - filterResult.filter=altTextFilter; - filterResult.keywordMatches=List.of(); - filters.add(filterResult); - } - return filters; - } - - public void updateAccountInfo(){ - AccountSessionManager.getInstance().updateSessionLocalInfo(this); - } - - public Optional getInstance() { - return Optional.ofNullable(AccountSessionManager.getInstance().getInstanceInfo(domain)); - } - - public Uri getInstanceUri() { - return new Uri.Builder() - .scheme("https") - .authority(getInstance().map(i -> i.normalizedUri).orElse(domain)) - .build(); - } - - public String getDefaultAvatarUrl() { - return getInstance() - .map(instance->"https://"+domain+(instance.isAkkoma() ? "/images/avi.png" : "/avatars/original/missing.png")) - .orElse(""); - } - - public boolean isNotificationsMentionsOnly(){ - return getRawLocalPreferences().getBoolean("notificationsMentionsOnly", false); - } - - public void setNotificationsMentionsOnly(boolean mentionsOnly){ - getRawLocalPreferences().edit().putBoolean("notificationsMentionsOnly", mentionsOnly).apply(); - } -} blob - /dev/null blob + c55bb9e9b37fa74dc67e69b9df7982f24caf81cf (mode 644) --- /dev/null +++ mastodon/src/main/java/org/joinmastodon/android/api/session/AccountSession.kt @@ -0,0 +1,418 @@ +package org.joinmastodon.android.api.session + +import android.app.Activity +import android.content.Context +import android.content.SharedPreferences +import android.net.Uri +import android.text.TextUtils +import android.util.Log +import me.grishka.appkit.api.Callback +import me.grishka.appkit.api.ErrorResponse +import org.joinmastodon.android.E +import org.joinmastodon.android.MastodonApp +import org.joinmastodon.android.R +import org.joinmastodon.android.api.CacheController +import org.joinmastodon.android.api.MastodonAPIController +import org.joinmastodon.android.api.PushSubscriptionManager +import org.joinmastodon.android.api.StatusInteractionController +import org.joinmastodon.android.api.requests.accounts.GetPreferences +import org.joinmastodon.android.api.requests.accounts.UpdateAccountCredentialsPreferences +import org.joinmastodon.android.api.requests.markers.GetMarkers +import org.joinmastodon.android.api.requests.markers.SaveMarkers +import org.joinmastodon.android.api.requests.oauth.RevokeOauthToken +import org.joinmastodon.android.events.NotificationsMarkerUpdatedEvent +import org.joinmastodon.android.model.Account +import org.joinmastodon.android.model.AltTextFilter +import org.joinmastodon.android.model.Application +import org.joinmastodon.android.model.FilterAction +import org.joinmastodon.android.model.FilterContext +import org.joinmastodon.android.model.FilterResult +import org.joinmastodon.android.model.Instance +import org.joinmastodon.android.model.LegacyFilter +import org.joinmastodon.android.model.Preferences +import org.joinmastodon.android.model.PushSubscription +import org.joinmastodon.android.model.Status +import org.joinmastodon.android.model.TimelineMarkers +import org.joinmastodon.android.model.Token +import org.joinmastodon.android.utils.ObjectIdComparator +import java.util.EnumSet +import java.util.Optional +import java.util.function.Consumer +import java.util.function.Function + +class AccountSession { + @JvmField + var token: Token? = null + @JvmField + var self: Account? = null + @JvmField + var domain: String? = null + @JvmField + var app: Application? = null + @JvmField + var infoLastUpdated: Long = 0 + @JvmField + var activated: Boolean = true + @JvmField + var pushPrivateKey: String? = null + @JvmField + var pushPublicKey: String? = null + @JvmField + var pushAuthKey: String? = null + @JvmField + var pushSubscription: PushSubscription? = null + @JvmField + var needUpdatePushSettings: Boolean = false + @JvmField + var filtersLastUpdated: Long = 0 + @JvmField + var wordFilters: MutableList? = ArrayList() + @JvmField + var pushAccountID: String? = null + @JvmField + var activationInfo: AccountActivationInfo? = null + @JvmField + var preferences: Preferences? = null + + val lastKnownNotificationsMarker: String? + get() = this.rawLocalPreferences?.getString("notificationsMarker", null) + + @Transient + var apiController: MastodonAPIController? = null + get() { + if (field == null) field = MastodonAPIController(this) + return field + } + private set + + @Transient + var statusInteractionController: StatusInteractionController? = null + get() { + if (field == null) field = StatusInteractionController(this.iD) + return field + } + private set + + @Transient + var remoteStatusInteractionController: StatusInteractionController? = null + get() { + if (field == null) field = StatusInteractionController(this.iD, false) + return field + } + private set + + @Transient + var cacheController: CacheController? = null + get() { + if (field == null) field = CacheController(this.iD) + return field + } + private set + + @Transient + var pushSubscriptionManager: PushSubscriptionManager? = null + get() { + if (field == null) field = PushSubscriptionManager(this.iD) + return field + } + private set + + @Transient + private var prefs: SharedPreferences? = null + + @Transient + private var preferencesNeedSaving = false + + @Transient + var localPreferences: AccountLocalPreferences? = null + get() { + if (field == null) field = AccountLocalPreferences(this.rawLocalPreferences, this) + return field + } + private set + + internal constructor( + token: Token, + self: Account, + app: Application, + domain: String?, + activated: Boolean, + activationInfo: AccountActivationInfo? + ) { + this.token = token + this.self = self + this.domain = domain + this.app = app + this.activated = activated + this.activationInfo = activationInfo + infoLastUpdated = System.currentTimeMillis() + } + + internal constructor() + + val iD: String + get() = domain + "_" + self?.id + + val fullUsername: String + get() = '@'.toString() + self?.username + '@' + domain + + fun preferencesFromAccountSource(account: Account?) { + if (account != null && account.source != null && preferences != null) { + if (account.source.privacy != null) preferences!!.postingDefaultVisibility = + account.source.privacy + if (account.source.language != null) preferences!!.postingDefaultLanguage = + account.source.language + } + } + + fun reloadPreferences(callback: Consumer?) { + GetPreferences() + .setCallback(object : Callback { + override fun onSuccess(result: Preferences) { + preferences = result + preferencesFromAccountSource(self) + callback?.accept(result) + AccountSessionManager.getInstance().writeAccountsFile() + } + + override fun onError(error: ErrorResponse) { + Log.w(TAG, "Failed to load preferences for account $iD: $error") + if (preferences == null) preferences = Preferences() + preferencesFromAccountSource(self) + } + }) + .exec(this.iD) + } + + val rawLocalPreferences: SharedPreferences? + get() { + if (prefs == null) prefs = MastodonApp.context.getSharedPreferences( + this.iD, + Context.MODE_PRIVATE + ) + return prefs + } + + fun reloadNotificationsMarker(callback: Consumer) { + GetMarkers() + .setCallback(object : Callback { + override fun onSuccess(result: TimelineMarkers) { + if (result.notifications != null && !TextUtils.isEmpty(result.notifications.lastReadId)) { + var id = result.notifications.lastReadId + val lastKnown: String? = lastKnownNotificationsMarker + if (ObjectIdComparator.INSTANCE.compare(id, lastKnown) < 0) { + // Marker moved back -- previous marker update must have failed. + // Pretend it didn't happen and repeat the request. + id = lastKnown + SaveMarkers(null, id).exec(iD) + } + callback.accept(id) + setNotificationsMarker(id, false) + } + } + + override fun onError(error: ErrorResponse?) {} + }) + .exec(this.iD) + } + + fun setNotificationsMarker(id: String?, clearUnread: Boolean) { + this.rawLocalPreferences?.edit()?.putString("notificationsMarker", id)?.apply() + E.post(NotificationsMarkerUpdatedEvent(this.iD, id, clearUnread)) + } + + fun logOut(activity: Activity?, onDone: Runnable) { + RevokeOauthToken(app?.clientId, app?.clientSecret, token?.accessToken) + .setCallback(object : Callback { + override fun onSuccess(result: Any?) { + AccountSessionManager.getInstance().removeAccount(iD) + onDone.run() + } + + override fun onError(error: ErrorResponse?) { + AccountSessionManager.getInstance().removeAccount(iD) + onDone.run() + } + }) + .wrapProgress(activity, R.string.loading, false) + .exec(this.iD) + } + + fun savePreferencesLater() { + preferencesNeedSaving = true + } + + fun savePreferencesIfPending() { + if (preferencesNeedSaving) { + UpdateAccountCredentialsPreferences( + preferences, + self?.locked, + self?.discoverable, + self?.source?.indexable + ) + .setCallback(object : Callback { + override fun onSuccess(result: Account?) { + preferencesNeedSaving = false + self = result + AccountSessionManager.getInstance().writeAccountsFile() + } + + override fun onError(error: ErrorResponse?) { + Log.e(TAG, "failed to save preferences: $error") + } + }) + .exec(this.iD) + } + } + + @JvmOverloads + fun filterStatuses( + statuses: MutableList, + context: FilterContext?, + profile: Account? = null + ) { + filterStatusContainingObjects( + statuses, + Function.identity(), + context, + profile + ) + } + + fun filterStatusContainingObjects( + objects: MutableList, + extractor: Function, + context: FilterContext? + ) { + filterStatusContainingObjects(objects, extractor, context, null) + } + + private fun statusIsOnOwnProfile(s: Status, profile: Account?): Boolean { + return self != null && profile != null && s.account != null && self!!.id == profile.id && self!!.id == s.account.id + } + + private fun isFilteredType(s: Status): Boolean { + val localPreferences = this.localPreferences!! + return (!localPreferences.showReplies && s.inReplyToId != null) + || (!localPreferences.showBoosts && s.reblog != null) + } + + fun filterStatusContainingObjects( + objects: MutableList, + extractor: Function, + context: FilterContext?, + profile: Account? + ) { + val localPreferences = this.localPreferences!! + if (!localPreferences.serverSideFiltersSupported) for (obj in objects) { + val s = extractor.apply(obj) + if (s != null && s.filtered != null) { + localPreferences.serverSideFiltersSupported = true + localPreferences.save() + break + } + } + + val removeUs: MutableList = ArrayList() + for (i in objects.indices) { + val o = objects[i] + if (filterStatusContainingObject(o, extractor, context, profile)) { + val s = extractor.apply(o) + removeUs.add(o) + if (s != null && s.hasGapAfter != null && i > 0) { + // oops, we're about to remove an item that has a gap after... + // gotta find the previous status that's not also about to be removed + for (j in i - 1 downTo 0) { + val p = objects[j] + val prev = extractor.apply(objects[j]) + if (prev != null && !removeUs.contains(p)) { + prev.hasGapAfter = s.hasGapAfter + break + } + } + } + } + } + objects.removeAll(removeUs) + } + + fun filterStatusContainingObject( + `object`: T?, + extractor: Function, + context: FilterContext?, + profile: Account? + ): Boolean { + val s = extractor.apply(`object`) ?: return false + // don't hide own posts in own profile + if (statusIsOnOwnProfile(s, profile)) return false + if (isFilteredType(s) && (context == FilterContext.HOME || context == FilterContext.PUBLIC)) return true + // Even with server-side filters, clients are expected to remove statuses that match a filter that hides them + if (this.localPreferences!!.serverSideFiltersSupported) { + // Moshidon: this code path in CustomLocalTimelines makes the app crash, so this check is here + if (s.filtered == null) return false + for (filter in s.filtered) { + if (filter.filter.isActive && filter.filter.filterAction == FilterAction.HIDE && filter.filter.context.contains( + context + ) + ) return true + } + } else if (wordFilters != null) { + for (filter in wordFilters) { + if (filter.context.contains(context) && filter.matches(s) && filter.isActive) return true + } + } + return false + } + + fun getClientSideFilters(status: Status): MutableList { + val filters: MutableList = ArrayList() + + // filter post that have no alt text + // it only applies when activated in the settings + val altTextFilter = AltTextFilter( + FilterAction.WARN, + EnumSet.allOf(FilterContext::class.java) + ) + if (altTextFilter.matches(status)) { + val filterResult = FilterResult() + filterResult.filter = altTextFilter + filterResult.keywordMatches = mutableListOf() + filters.add(filterResult) + } + return filters + } + + fun updateAccountInfo() { + AccountSessionManager.getInstance().updateSessionLocalInfo(this) + } + + val instance: Optional + get() = Optional.ofNullable( + AccountSessionManager.getInstance().getInstanceInfo(domain) + ) + + val instanceUri: Uri? + get() = Uri.Builder() + .scheme("https") + .authority( + this.instance.map(Function { i: Instance -> i.normalizedUri }) + .orElse(domain) + ) + .build() + + val defaultAvatarUrl: String + get() = this.instance + .map(Function { instance: Instance -> "https://" + domain + (if (instance.isAkkoma) "/images/avi.png" else "/avatars/original/missing.png") }) + .orElse("") + + var isNotificationsMentionsOnly: Boolean + get() = this.rawLocalPreferences!!.getBoolean("notificationsMentionsOnly", false) + set(mentionsOnly) { + this.rawLocalPreferences!!.edit().putBoolean("notificationsMentionsOnly", mentionsOnly) + .apply() + } + + companion object { + private const val TAG = "AccountSession" + } +}