megalodon/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountSessionManager.java

511 lines
17 KiB
Java

package org.joinmastodon.android.api.session;
import static org.unifiedpush.android.connector.UnifiedPush.getDistributor;
import android.app.Activity;
import android.app.NotificationManager;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.content.pm.ShortcutInfo;
import android.content.pm.ShortcutManager;
import android.graphics.drawable.Icon;
import android.net.Uri;
import android.os.Build;
import android.util.Log;
import org.joinmastodon.android.BuildConfig;
import org.joinmastodon.android.E;
import org.joinmastodon.android.GlobalUserPreferences;
import org.joinmastodon.android.MainActivity;
import org.joinmastodon.android.MastodonApp;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.MastodonAPIController;
import org.joinmastodon.android.api.PushSubscriptionManager;
import org.joinmastodon.android.api.requests.filters.GetLegacyFilters;
import org.joinmastodon.android.api.requests.instance.GetCustomEmojis;
import org.joinmastodon.android.api.requests.accounts.GetOwnAccount;
import org.joinmastodon.android.api.requests.instance.GetInstance;
import org.joinmastodon.android.api.requests.oauth.CreateOAuthApp;
import org.joinmastodon.android.events.EmojiUpdatedEvent;
import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.model.Application;
import org.joinmastodon.android.model.Emoji;
import org.joinmastodon.android.model.EmojiCategory;
import org.joinmastodon.android.model.LegacyFilter;
import org.joinmastodon.android.model.Instance;
import org.joinmastodon.android.model.Token;
import org.unifiedpush.android.connector.UnifiedPush;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.browser.customtabs.CustomTabsIntent;
import me.grishka.appkit.api.Callback;
import me.grishka.appkit.api.ErrorResponse;
public class AccountSessionManager{
private static final String TAG="AccountSessionManager";
public static final String SCOPE="read write follow push";
public static final String REDIRECT_URI="megalodon-android-auth://callback";
private static final AccountSessionManager instance=new AccountSessionManager();
private HashMap<String, AccountSession> sessions=new HashMap<>();
private HashMap<String, List<EmojiCategory>> customEmojis=new HashMap<>();
private HashMap<String, Long> instancesLastUpdated=new HashMap<>();
private HashMap<String, Instance> instances=new HashMap<>();
private MastodonAPIController unauthenticatedApiController=new MastodonAPIController(null);
private Instance authenticatingInstance;
private Application authenticatingApp;
private String lastActiveAccountID;
private SharedPreferences prefs;
private boolean loadedInstances;
public static AccountSessionManager getInstance(){
return instance;
}
private AccountSessionManager(){
prefs=MastodonApp.context.getSharedPreferences("account_manager", Context.MODE_PRIVATE);
File file=new File(MastodonApp.context.getFilesDir(), "accounts.json");
if(!file.exists())
return;
HashSet<String> domains=new HashSet<>();
try(FileInputStream in=new FileInputStream(file)){
SessionsStorageWrapper w=MastodonAPIController.gson.fromJson(new InputStreamReader(in, StandardCharsets.UTF_8), SessionsStorageWrapper.class);
for(AccountSession session:w.accounts){
domains.add(session.domain.toLowerCase());
sessions.put(session.getID(), session);
}
}catch(Exception x){
Log.e(TAG, "Error loading accounts", x);
}
lastActiveAccountID=prefs.getString("lastActiveAccount", null);
MastodonAPIController.runInBackground(()->readInstanceInfo(domains));
maybeUpdateShortcuts();
}
public void addAccount(Instance instance, Token token, Account self, Application app, AccountActivationInfo activationInfo){
Context context = MastodonApp.context;
instances.put(instance.uri, instance);
AccountSession session=new AccountSession(token, self, app, instance.uri, activationInfo==null, activationInfo);
sessions.put(session.getID(), session);
lastActiveAccountID=session.getID();
writeAccountsFile();
// write initial instance info to file immediately to avoid sessions without instance info
InstanceInfoStorageWrapper wrapper = new InstanceInfoStorageWrapper();
wrapper.instance = instance;
MastodonAPIController.runInBackground(()->writeInstanceInfoFile(wrapper, instance.uri));
updateMoreInstanceInfo(instance, instance.uri);
if (!UnifiedPush.getDistributor(context).isEmpty()) {
UnifiedPush.registerApp(
context,
session.getID(),
new ArrayList<>(),
context.getPackageName()
);
} else if(PushSubscriptionManager.arePushNotificationsAvailable()){
session.getPushSubscriptionManager().registerAccountForPush(null);
}
maybeUpdateShortcuts();
}
public synchronized void writeAccountsFile(){
File tmpFile = new File(MastodonApp.context.getFilesDir(), "accounts.json~");
File file = new File(MastodonApp.context.getFilesDir(), "accounts.json");
try{
try(FileOutputStream out=new FileOutputStream(tmpFile)){
SessionsStorageWrapper w=new SessionsStorageWrapper();
w.accounts=new ArrayList<>(sessions.values());
OutputStreamWriter writer=new OutputStreamWriter(out, StandardCharsets.UTF_8);
MastodonAPIController.gson.toJson(w, writer);
writer.flush();
if (!tmpFile.renameTo(file)) Log.e(TAG, "Error renaming " + tmpFile.getPath() + " to " + file.getPath());
}
}catch(IOException x){
Log.e(TAG, "Error writing accounts file", x);
}
prefs.edit().putString("lastActiveAccount", lastActiveAccountID).apply();
}
@NonNull
public List<AccountSession> getLoggedInAccounts(){
return new ArrayList<>(sessions.values());
}
@NonNull
public AccountSession getAccount(String id){
AccountSession session=sessions.get(id);
if(session==null)
throw new IllegalStateException("Account session "+id+" not found");
return session;
}
public static AccountSession get(String id){
return getInstance().getAccount(id);
}
@Nullable
public AccountSession tryGetAccount(String id){
return sessions.get(id);
}
public static Optional<AccountSession> getOptional(String id) {
return Optional.ofNullable(getInstance().tryGetAccount(id));
}
@Nullable
public AccountSession tryGetAccount(Account account) {
return sessions.get(account.getDomainFromURL() + "_" + account.id);
}
@Nullable
public AccountSession getLastActiveAccount(){
if(sessions.isEmpty() || lastActiveAccountID==null)
return null;
if(!sessions.containsKey(lastActiveAccountID)){
// TODO figure out why this happens. It should not be possible.
lastActiveAccountID=getLoggedInAccounts().get(0).getID();
writeAccountsFile();
}
return getAccount(lastActiveAccountID);
}
public String getLastActiveAccountID(){
return lastActiveAccountID;
}
public void setLastActiveAccountID(String id){
if(!sessions.containsKey(id))
throw new IllegalStateException("Account session "+id+" not found");
lastActiveAccountID=id;
prefs.edit().putString("lastActiveAccount", id).apply();
}
public void removeAccount(String id){
AccountSession session=getAccount(id);
session.getCacheController().closeDatabase();
session.getCacheController().getListsFile().delete();
MastodonApp.context.deleteDatabase(id+".db");
MastodonApp.context.getSharedPreferences(id, 0).edit().clear().commit();
if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.N){
MastodonApp.context.deleteSharedPreferences(id);
}else{
String dataDir=MastodonApp.context.getApplicationInfo().dataDir;
if(dataDir!=null){
File prefsDir=new File(dataDir, "shared_prefs");
new File(prefsDir, id+".xml").delete();
}
}
sessions.remove(id);
if(lastActiveAccountID.equals(id)){
if(sessions.isEmpty())
lastActiveAccountID=null;
else
lastActiveAccountID=getLoggedInAccounts().get(0).getID();
prefs.edit().putString("lastActiveAccount", lastActiveAccountID).apply();
}
writeAccountsFile();
String domain=session.domain.toLowerCase();
if(sessions.isEmpty() || !sessions.values().stream().map(s->s.domain.toLowerCase()).collect(Collectors.toSet()).contains(domain)){
getInstanceInfoFile(domain).delete();
}
if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.O){
NotificationManager nm=MastodonApp.context.getSystemService(NotificationManager.class);
nm.deleteNotificationChannelGroup(id);
}
maybeUpdateShortcuts();
}
@NonNull
public MastodonAPIController getUnauthenticatedApiController(){
return unauthenticatedApiController;
}
public void authenticate(Activity activity, Instance instance){
authenticatingInstance=instance;
new CreateOAuthApp()
.setCallback(new Callback<>(){
@Override
public void onSuccess(Application result){
authenticatingApp=result;
Uri uri=new Uri.Builder()
.scheme("https")
.authority(instance.uri)
.path("/oauth/authorize")
.appendQueryParameter("response_type", "code")
.appendQueryParameter("client_id", result.clientId)
.appendQueryParameter("redirect_uri", "megalodon-android-auth://callback")
.appendQueryParameter("scope", SCOPE)
.build();
new CustomTabsIntent.Builder()
.setShareState(CustomTabsIntent.SHARE_STATE_OFF)
.setShowTitle(true)
.build()
.launchUrl(activity, uri);
}
@Override
public void onError(ErrorResponse error){
error.showToast(activity);
}
})
.wrapProgress(activity, R.string.preparing_auth, false)
.execNoAuth(instance.uri);
}
public boolean isSelf(String id, Account other){
return getAccount(id).self.id.equals(other.id);
}
public Instance getAuthenticatingInstance(){
return authenticatingInstance;
}
public Application getAuthenticatingApp(){
return authenticatingApp;
}
public void maybeUpdateLocalInfo(){
maybeUpdateLocalInfo(null);
}
public void maybeUpdateLocalInfo(AccountSession activeSession){
long now=System.currentTimeMillis();
HashSet<String> domains=new HashSet<>();
for(AccountSession session:sessions.values()){
domains.add(session.domain.toLowerCase());
if(session == activeSession || now-session.infoLastUpdated>24L*3600_000L){
session.reloadPreferences(null);
updateSessionLocalInfo(session);
}
if(session == activeSession || (session.getLocalPreferences().serverSideFiltersSupported && now-session.filtersLastUpdated>3600_000L)){
updateSessionWordFilters(session);
}
}
if(loadedInstances){
maybeUpdateCustomEmojis(domains, activeSession != null ? activeSession.domain : null);
}
}
private void maybeUpdateCustomEmojis(Set<String> domains, String activeDomain){
long now=System.currentTimeMillis();
for(String domain:domains){
Long lastUpdated=instancesLastUpdated.get(domain);
if(domain.equals(activeDomain) || lastUpdated==null || now-lastUpdated>24L*3600_000L){
updateInstanceInfo(domain);
}
}
}
/*package*/ void updateSessionLocalInfo(AccountSession session){
new GetOwnAccount()
.setCallback(new Callback<>(){
@Override
public void onSuccess(Account result){
session.self=result;
session.infoLastUpdated=System.currentTimeMillis();
session.preferencesFromAccountSource(result);
writeAccountsFile();
}
@Override
public void onError(ErrorResponse error){
}
})
.exec(session.getID());
}
private void updateSessionWordFilters(AccountSession session){
new GetLegacyFilters()
.setCallback(new Callback<>(){
@Override
public void onSuccess(List<LegacyFilter> result){
session.wordFilters=result;
session.filtersLastUpdated=System.currentTimeMillis();
writeAccountsFile();
}
@Override
public void onError(ErrorResponse error){
}
})
.exec(session.getID());
}
public void updateInstanceInfo(String domain){
new GetInstance()
.setCallback(new Callback<>(){
@Override
public void onSuccess(Instance instance){
instances.put(domain, instance);
updateMoreInstanceInfo(instance, domain);
}
@Override
public void onError(ErrorResponse error){
}
})
.execNoAuth(domain);
}
public void updateMoreInstanceInfo(Instance instance, String domain) {
new GetInstance.V2().setCallback(new Callback<>() {
@Override
public void onSuccess(Instance.V2 v2) {
if (instance != null) instance.v2 = v2;
updateInstanceEmojis(instance, domain);
}
@Override
public void onError(ErrorResponse errorResponse) {
updateInstanceEmojis(instance, domain);
}
}).execNoAuth(instance.uri);
}
private void updateInstanceEmojis(Instance instance, String domain){
new GetCustomEmojis()
.setCallback(new Callback<>(){
@Override
public void onSuccess(List<Emoji> result){
InstanceInfoStorageWrapper emojis=new InstanceInfoStorageWrapper();
emojis.lastUpdated=System.currentTimeMillis();
emojis.emojis=result;
emojis.instance=instance;
customEmojis.put(domain, groupCustomEmojis(emojis));
instancesLastUpdated.put(domain, emojis.lastUpdated);
MastodonAPIController.runInBackground(()->writeInstanceInfoFile(emojis, domain));
E.post(new EmojiUpdatedEvent(domain));
}
@Override
public void onError(ErrorResponse error){
InstanceInfoStorageWrapper wrapper=new InstanceInfoStorageWrapper();
wrapper.instance = instance;
MastodonAPIController.runInBackground(()->writeInstanceInfoFile(wrapper, domain));
}
})
.execNoAuth(domain);
}
private File getInstanceInfoFile(String domain){
return new File(MastodonApp.context.getFilesDir(), "instance_"+domain.replace('.', '_')+".json");
}
private void writeInstanceInfoFile(InstanceInfoStorageWrapper emojis, String domain){
File file = getInstanceInfoFile(domain);
File tmpFile = new File(file.getPath() + "~");
try(FileOutputStream out=new FileOutputStream(tmpFile)){
OutputStreamWriter writer=new OutputStreamWriter(out, StandardCharsets.UTF_8);
MastodonAPIController.gson.toJson(emojis, writer);
writer.flush();
if (!tmpFile.renameTo(file)) Log.e(TAG, "Error renaming " + tmpFile.getPath() + " to " + file.getPath());
}catch(IOException x){
Log.w(TAG, "Error writing instance info file for "+domain, x);
}
}
private void readInstanceInfo(Set<String> domains){
for(String domain:domains){
try(FileInputStream in=new FileInputStream(getInstanceInfoFile(domain))){
InputStreamReader reader=new InputStreamReader(in, StandardCharsets.UTF_8);
InstanceInfoStorageWrapper emojis=MastodonAPIController.gson.fromJson(reader, InstanceInfoStorageWrapper.class);
customEmojis.put(domain, groupCustomEmojis(emojis));
instances.put(domain, emojis.instance);
instancesLastUpdated.put(domain, emojis.lastUpdated);
}catch(Exception x){
Log.w(TAG, "Error reading instance info file for "+domain, x);
}
}
if(!loadedInstances){
loadedInstances=true;
maybeUpdateCustomEmojis(domains, null);
}
}
private List<EmojiCategory> groupCustomEmojis(InstanceInfoStorageWrapper emojis){
return emojis.emojis.stream()
.filter(e->e.visibleInPicker)
.collect(Collectors.groupingBy(e->e.category==null ? "" : e.category))
.entrySet()
.stream()
.map(e->new EmojiCategory(e.getKey(), e.getValue()))
.sorted(Comparator.comparing(c->c.title))
.collect(Collectors.toList());
}
public List<EmojiCategory> getCustomEmojis(String domain){
List<EmojiCategory> r=customEmojis.get(domain.toLowerCase());
return r==null ? Collections.emptyList() : r;
}
public Instance getInstanceInfo(String domain){
return instances.get(domain);
}
public void updateAccountInfo(String id, Account account){
AccountSession session=getAccount(id);
session.self=account;
session.infoLastUpdated=System.currentTimeMillis();
writeAccountsFile();
}
private void maybeUpdateShortcuts(){
if(Build.VERSION.SDK_INT<26)
return;
ShortcutManager sm=MastodonApp.context.getSystemService(ShortcutManager.class);
if((sm.getDynamicShortcuts().isEmpty() || BuildConfig.DEBUG) && !sessions.isEmpty()){
// There are no shortcuts, but there are accounts. Add a compose shortcut.
ShortcutInfo info=new ShortcutInfo.Builder(MastodonApp.context, "compose")
.setActivity(ComponentName.createRelative(MastodonApp.context, MainActivity.class.getName()))
.setShortLabel(MastodonApp.context.getString(R.string.new_post))
.setIcon(Icon.createWithResource(MastodonApp.context, R.mipmap.ic_shortcut_compose))
.setIntent(new Intent(MastodonApp.context, MainActivity.class)
.setAction(Intent.ACTION_MAIN)
.putExtra("compose", true))
.build();
sm.setDynamicShortcuts(Collections.singletonList(info));
}else if(sessions.isEmpty()){
// There are shortcuts, but no accounts. Disable existing shortcuts.
sm.disableShortcuts(Collections.singletonList("compose"), MastodonApp.context.getString(R.string.err_not_logged_in));
}else{
sm.enableShortcuts(Collections.singletonList("compose"));
}
}
private static class SessionsStorageWrapper{
public List<AccountSession> accounts;
}
private static class InstanceInfoStorageWrapper{
public Instance instance;
public List<Emoji> emojis;
public long lastUpdated;
}
}