Image resizing
This commit is contained in:
parent
d47eb752a5
commit
e087cf03cc
|
@ -10,7 +10,7 @@ android {
|
||||||
applicationId "org.joinmastodon.android"
|
applicationId "org.joinmastodon.android"
|
||||||
minSdk 23
|
minSdk 23
|
||||||
targetSdk 31
|
targetSdk 31
|
||||||
versionCode 15
|
versionCode 16
|
||||||
versionName "0.1"
|
versionName "0.1"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -2,31 +2,22 @@ package org.joinmastodon.android.api;
|
||||||
|
|
||||||
import android.database.Cursor;
|
import android.database.Cursor;
|
||||||
import android.net.Uri;
|
import android.net.Uri;
|
||||||
import android.os.SystemClock;
|
|
||||||
import android.provider.OpenableColumns;
|
import android.provider.OpenableColumns;
|
||||||
|
|
||||||
import org.joinmastodon.android.MastodonApp;
|
import org.joinmastodon.android.MastodonApp;
|
||||||
import org.joinmastodon.android.ui.utils.UiUtils;
|
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
|
||||||
import okhttp3.MediaType;
|
import okhttp3.MediaType;
|
||||||
import okhttp3.RequestBody;
|
|
||||||
import okio.Buffer;
|
|
||||||
import okio.BufferedSink;
|
|
||||||
import okio.ForwardingSink;
|
|
||||||
import okio.Okio;
|
import okio.Okio;
|
||||||
import okio.Sink;
|
|
||||||
import okio.Source;
|
import okio.Source;
|
||||||
|
|
||||||
public class ContentUriRequestBody extends RequestBody{
|
public class ContentUriRequestBody extends CountingRequestBody{
|
||||||
private final Uri uri;
|
private final Uri uri;
|
||||||
private final long length;
|
|
||||||
private ProgressListener progressListener;
|
|
||||||
|
|
||||||
public ContentUriRequestBody(Uri uri, ProgressListener progressListener){
|
public ContentUriRequestBody(Uri uri, ProgressListener progressListener){
|
||||||
|
super(progressListener);
|
||||||
this.uri=uri;
|
this.uri=uri;
|
||||||
this.progressListener=progressListener;
|
|
||||||
try(Cursor cursor=MastodonApp.context.getContentResolver().query(uri, new String[]{OpenableColumns.SIZE}, null, null, null)){
|
try(Cursor cursor=MastodonApp.context.getContentResolver().query(uri, new String[]{OpenableColumns.SIZE}, null, null, null)){
|
||||||
cursor.moveToFirst();
|
cursor.moveToFirst();
|
||||||
length=cursor.getInt(0);
|
length=cursor.getInt(0);
|
||||||
|
@ -39,40 +30,7 @@ public class ContentUriRequestBody extends RequestBody{
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public long contentLength() throws IOException{
|
protected Source openSource() throws IOException{
|
||||||
return length;
|
return Okio.source(MastodonApp.context.getContentResolver().openInputStream(uri));
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void writeTo(BufferedSink sink) throws IOException{
|
|
||||||
if(progressListener!=null){
|
|
||||||
try(Source source=Okio.source(MastodonApp.context.getContentResolver().openInputStream(uri))){
|
|
||||||
BufferedSink wrappedSink=Okio.buffer(new CountingSink(sink));
|
|
||||||
wrappedSink.writeAll(source);
|
|
||||||
wrappedSink.flush();
|
|
||||||
}
|
|
||||||
}else{
|
|
||||||
try(Source source=Okio.source(MastodonApp.context.getContentResolver().openInputStream(uri))){
|
|
||||||
sink.writeAll(source);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private class CountingSink extends ForwardingSink{
|
|
||||||
private long bytesWritten=0;
|
|
||||||
private long lastCallbackTime;
|
|
||||||
public CountingSink(Sink delegate){
|
|
||||||
super(delegate);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void write(Buffer source, long byteCount) throws IOException{
|
|
||||||
super.write(source, byteCount);
|
|
||||||
bytesWritten+=byteCount;
|
|
||||||
if(SystemClock.uptimeMillis()-lastCallbackTime>=100L || bytesWritten==length){
|
|
||||||
lastCallbackTime=SystemClock.uptimeMillis();
|
|
||||||
UiUtils.runOnUiThread(()->progressListener.onProgress(bytesWritten, length));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,39 @@
|
||||||
|
package org.joinmastodon.android.api;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
|
||||||
|
import okhttp3.RequestBody;
|
||||||
|
import okio.BufferedSink;
|
||||||
|
import okio.Okio;
|
||||||
|
import okio.Source;
|
||||||
|
|
||||||
|
abstract class CountingRequestBody extends RequestBody{
|
||||||
|
protected long length;
|
||||||
|
protected ProgressListener progressListener;
|
||||||
|
|
||||||
|
CountingRequestBody(ProgressListener progressListener){
|
||||||
|
this.progressListener=progressListener;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public long contentLength() throws IOException{
|
||||||
|
return length;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void writeTo(BufferedSink sink) throws IOException{
|
||||||
|
if(progressListener!=null){
|
||||||
|
try(Source source=openSource()){
|
||||||
|
BufferedSink wrappedSink=Okio.buffer(new CountingSink(length, progressListener, sink));
|
||||||
|
wrappedSink.writeAll(source);
|
||||||
|
wrappedSink.flush();
|
||||||
|
}
|
||||||
|
}else{
|
||||||
|
try(Source source=openSource()){
|
||||||
|
sink.writeAll(source);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected abstract Source openSource() throws IOException;
|
||||||
|
}
|
|
@ -0,0 +1,34 @@
|
||||||
|
package org.joinmastodon.android.api;
|
||||||
|
|
||||||
|
import android.os.SystemClock;
|
||||||
|
|
||||||
|
import org.joinmastodon.android.ui.utils.UiUtils;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
|
||||||
|
import okio.Buffer;
|
||||||
|
import okio.ForwardingSink;
|
||||||
|
import okio.Sink;
|
||||||
|
|
||||||
|
class CountingSink extends ForwardingSink{
|
||||||
|
private long bytesWritten=0;
|
||||||
|
private long lastCallbackTime;
|
||||||
|
private final long length;
|
||||||
|
private final ProgressListener progressListener;
|
||||||
|
|
||||||
|
public CountingSink(long length, ProgressListener progressListener, Sink delegate){
|
||||||
|
super(delegate);
|
||||||
|
this.length=length;
|
||||||
|
this.progressListener=progressListener;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void write(Buffer source, long byteCount) throws IOException{
|
||||||
|
super.write(source, byteCount);
|
||||||
|
bytesWritten+=byteCount;
|
||||||
|
if(SystemClock.uptimeMillis()-lastCallbackTime>=100L || bytesWritten==length){
|
||||||
|
lastCallbackTime=SystemClock.uptimeMillis();
|
||||||
|
UiUtils.runOnUiThread(()->progressListener.onProgress(bytesWritten, length));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -135,7 +135,7 @@ public abstract class MastodonAPIRequest<T> extends APIRequest<T>{
|
||||||
return method;
|
return method;
|
||||||
}
|
}
|
||||||
|
|
||||||
public RequestBody getRequestBody(){
|
public RequestBody getRequestBody() throws IOException{
|
||||||
return requestBody==null ? null : new JsonObjectRequestBody(requestBody);
|
return requestBody==null ? null : new JsonObjectRequestBody(requestBody);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,128 @@
|
||||||
|
package org.joinmastodon.android.api;
|
||||||
|
|
||||||
|
import android.database.Cursor;
|
||||||
|
import android.graphics.Bitmap;
|
||||||
|
import android.graphics.BitmapFactory;
|
||||||
|
import android.graphics.Canvas;
|
||||||
|
import android.graphics.ImageDecoder;
|
||||||
|
import android.graphics.Matrix;
|
||||||
|
import android.graphics.Paint;
|
||||||
|
import android.graphics.Rect;
|
||||||
|
import android.media.ExifInterface;
|
||||||
|
import android.net.Uri;
|
||||||
|
import android.os.Build;
|
||||||
|
import android.provider.OpenableColumns;
|
||||||
|
|
||||||
|
import org.joinmastodon.android.MastodonApp;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.FileOutputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
import okhttp3.MediaType;
|
||||||
|
import okio.BufferedSink;
|
||||||
|
import okio.Okio;
|
||||||
|
import okio.Source;
|
||||||
|
|
||||||
|
public class ResizedImageRequestBody extends CountingRequestBody{
|
||||||
|
private File tempFile;
|
||||||
|
private Uri uri;
|
||||||
|
private String contentType;
|
||||||
|
|
||||||
|
public ResizedImageRequestBody(Uri uri, int maxSize, ProgressListener progressListener) throws IOException{
|
||||||
|
super(progressListener);
|
||||||
|
this.uri=uri;
|
||||||
|
contentType=MastodonApp.context.getContentResolver().getType(uri);
|
||||||
|
BitmapFactory.Options opts=new BitmapFactory.Options();
|
||||||
|
opts.inJustDecodeBounds=true;
|
||||||
|
try(InputStream in=MastodonApp.context.getContentResolver().openInputStream(uri)){
|
||||||
|
BitmapFactory.decodeStream(in, null, opts);
|
||||||
|
}
|
||||||
|
if(opts.outWidth*opts.outHeight>maxSize){
|
||||||
|
Bitmap bitmap;
|
||||||
|
if(Build.VERSION.SDK_INT>=29){
|
||||||
|
bitmap=ImageDecoder.decodeBitmap(ImageDecoder.createSource(MastodonApp.context.getContentResolver(), uri), (decoder, info, source)->{
|
||||||
|
int targetWidth=Math.round((float)Math.sqrt((float)maxSize*((float)info.getSize().getWidth()/info.getSize().getHeight())));
|
||||||
|
int targetHeight=Math.round((float)Math.sqrt((float)maxSize*((float)info.getSize().getHeight()/info.getSize().getWidth())));
|
||||||
|
decoder.setAllocator(ImageDecoder.ALLOCATOR_SOFTWARE);
|
||||||
|
decoder.setTargetSize(targetWidth, targetHeight);
|
||||||
|
});
|
||||||
|
}else{
|
||||||
|
int targetWidth=Math.round((float)Math.sqrt((float)maxSize*((float)opts.outWidth/opts.outHeight)));
|
||||||
|
int targetHeight=Math.round((float)Math.sqrt((float)maxSize*((float)opts.outHeight/opts.outWidth)));
|
||||||
|
float factor=opts.outWidth/(float)targetWidth;
|
||||||
|
opts=new BitmapFactory.Options();
|
||||||
|
opts.inSampleSize=(int)factor;
|
||||||
|
try(InputStream in=MastodonApp.context.getContentResolver().openInputStream(uri)){
|
||||||
|
bitmap=BitmapFactory.decodeStream(in, null, opts);
|
||||||
|
}
|
||||||
|
if(factor%1f!=0f){
|
||||||
|
Bitmap scaled=Bitmap.createBitmap(targetWidth, targetHeight, Bitmap.Config.ARGB_8888);
|
||||||
|
new Canvas(scaled).drawBitmap(bitmap, null, new Rect(0, 0, bitmap.getWidth(), bitmap.getHeight()), new Paint(Paint.FILTER_BITMAP_FLAG));
|
||||||
|
bitmap=scaled;
|
||||||
|
}
|
||||||
|
if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.N){
|
||||||
|
int rotation;
|
||||||
|
try(InputStream in=MastodonApp.context.getContentResolver().openInputStream(uri)){
|
||||||
|
ExifInterface exif=new ExifInterface(in);
|
||||||
|
int orientation=exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL);
|
||||||
|
rotation=switch(orientation){
|
||||||
|
case ExifInterface.ORIENTATION_ROTATE_90 -> 90;
|
||||||
|
case ExifInterface.ORIENTATION_ROTATE_180 -> 180;
|
||||||
|
case ExifInterface.ORIENTATION_ROTATE_270 -> 270;
|
||||||
|
default -> 0;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if(rotation!=0){
|
||||||
|
Matrix matrix=new Matrix();
|
||||||
|
matrix.setRotate(rotation);
|
||||||
|
bitmap=Bitmap.createBitmap(bitmap, 0, 0, bitmap.getWidth(), bitmap.getHeight(), matrix, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tempFile=new File(MastodonApp.context.getCacheDir(), "tmp_upload_image");
|
||||||
|
try(FileOutputStream out=new FileOutputStream(tempFile)){
|
||||||
|
if("image/png".equals(contentType)){
|
||||||
|
bitmap.compress(Bitmap.CompressFormat.PNG, 0, out);
|
||||||
|
}else{
|
||||||
|
bitmap.compress(Bitmap.CompressFormat.JPEG, 97, out);
|
||||||
|
contentType="image/jpeg";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
length=tempFile.length();
|
||||||
|
}else{
|
||||||
|
try(Cursor cursor=MastodonApp.context.getContentResolver().query(uri, new String[]{OpenableColumns.SIZE}, null, null, null)){
|
||||||
|
cursor.moveToFirst();
|
||||||
|
length=cursor.getInt(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected Source openSource() throws IOException{
|
||||||
|
if(tempFile==null){
|
||||||
|
return Okio.source(MastodonApp.context.getContentResolver().openInputStream(uri));
|
||||||
|
}else{
|
||||||
|
return Okio.source(tempFile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public MediaType contentType(){
|
||||||
|
return MediaType.get(contentType);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void writeTo(BufferedSink sink) throws IOException{
|
||||||
|
try{
|
||||||
|
super.writeTo(sink);
|
||||||
|
}finally{
|
||||||
|
if(tempFile!=null){
|
||||||
|
tempFile.delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -8,31 +8,40 @@ import org.joinmastodon.android.MastodonApp;
|
||||||
import org.joinmastodon.android.api.ContentUriRequestBody;
|
import org.joinmastodon.android.api.ContentUriRequestBody;
|
||||||
import org.joinmastodon.android.api.MastodonAPIRequest;
|
import org.joinmastodon.android.api.MastodonAPIRequest;
|
||||||
import org.joinmastodon.android.api.ProgressListener;
|
import org.joinmastodon.android.api.ProgressListener;
|
||||||
|
import org.joinmastodon.android.api.ResizedImageRequestBody;
|
||||||
import org.joinmastodon.android.model.Attachment;
|
import org.joinmastodon.android.model.Attachment;
|
||||||
import org.joinmastodon.android.ui.utils.UiUtils;
|
import org.joinmastodon.android.ui.utils.UiUtils;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
|
||||||
import okhttp3.MultipartBody;
|
import okhttp3.MultipartBody;
|
||||||
import okhttp3.RequestBody;
|
import okhttp3.RequestBody;
|
||||||
|
|
||||||
public class UploadAttachment extends MastodonAPIRequest<Attachment>{
|
public class UploadAttachment extends MastodonAPIRequest<Attachment>{
|
||||||
private Uri uri;
|
private Uri uri;
|
||||||
private ProgressListener progressListener;
|
private ProgressListener progressListener;
|
||||||
|
private int maxImageSize;
|
||||||
|
|
||||||
public UploadAttachment(Uri uri){
|
public UploadAttachment(Uri uri){
|
||||||
super(HttpMethod.POST, "/media", Attachment.class);
|
super(HttpMethod.POST, "/media", Attachment.class);
|
||||||
this.uri=uri;
|
this.uri=uri;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public UploadAttachment(Uri uri, int maxImageSize){
|
||||||
|
this(uri);
|
||||||
|
this.maxImageSize=maxImageSize;
|
||||||
|
}
|
||||||
|
|
||||||
public UploadAttachment setProgressListener(ProgressListener progressListener){
|
public UploadAttachment setProgressListener(ProgressListener progressListener){
|
||||||
this.progressListener=progressListener;
|
this.progressListener=progressListener;
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public RequestBody getRequestBody(){
|
public RequestBody getRequestBody() throws IOException{
|
||||||
return new MultipartBody.Builder()
|
return new MultipartBody.Builder()
|
||||||
.setType(MultipartBody.FORM)
|
.setType(MultipartBody.FORM)
|
||||||
.addFormDataPart("file", UiUtils.getFileName(uri), new ContentUriRequestBody(uri, progressListener))
|
.addFormDataPart("file", UiUtils.getFileName(uri), maxImageSize>0 ? new ResizedImageRequestBody(uri, maxImageSize, progressListener) : new ContentUriRequestBody(uri, progressListener))
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -12,25 +12,20 @@ public class AccountSession{
|
||||||
public Token token;
|
public Token token;
|
||||||
public Account self;
|
public Account self;
|
||||||
public String domain;
|
public String domain;
|
||||||
public int tootCharLimit;
|
|
||||||
public Application app;
|
public Application app;
|
||||||
public long infoLastUpdated;
|
public long infoLastUpdated;
|
||||||
public long instanceLastUpdated;
|
|
||||||
public Instance instance;
|
|
||||||
public boolean activated=true;
|
public boolean activated=true;
|
||||||
private transient MastodonAPIController apiController;
|
private transient MastodonAPIController apiController;
|
||||||
private transient StatusInteractionController statusInteractionController;
|
private transient StatusInteractionController statusInteractionController;
|
||||||
private transient CacheController cacheController;
|
private transient CacheController cacheController;
|
||||||
|
|
||||||
AccountSession(Token token, Account self, Application app, String domain, int tootCharLimit, Instance instance, boolean activated){
|
AccountSession(Token token, Account self, Application app, String domain, boolean activated){
|
||||||
this.token=token;
|
this.token=token;
|
||||||
this.self=self;
|
this.self=self;
|
||||||
this.domain=domain;
|
this.domain=domain;
|
||||||
this.app=app;
|
this.app=app;
|
||||||
this.tootCharLimit=tootCharLimit;
|
|
||||||
this.instance=instance;
|
|
||||||
this.activated=activated;
|
this.activated=activated;
|
||||||
instanceLastUpdated=infoLastUpdated=System.currentTimeMillis();
|
infoLastUpdated=System.currentTimeMillis();
|
||||||
}
|
}
|
||||||
|
|
||||||
AccountSession(){}
|
AccountSession(){}
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
package org.joinmastodon.android.api.session;
|
package org.joinmastodon.android.api.session;
|
||||||
|
|
||||||
import android.app.Activity;
|
import android.app.Activity;
|
||||||
import android.app.ProgressDialog;
|
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
import android.content.SharedPreferences;
|
import android.content.SharedPreferences;
|
||||||
import android.net.Uri;
|
import android.net.Uri;
|
||||||
|
@ -37,12 +36,10 @@ import java.util.HashMap;
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.function.Consumer;
|
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
import androidx.annotation.NonNull;
|
import androidx.annotation.NonNull;
|
||||||
import androidx.annotation.Nullable;
|
import androidx.annotation.Nullable;
|
||||||
import androidx.annotation.StringRes;
|
|
||||||
import androidx.browser.customtabs.CustomTabsIntent;
|
import androidx.browser.customtabs.CustomTabsIntent;
|
||||||
import me.grishka.appkit.api.Callback;
|
import me.grishka.appkit.api.Callback;
|
||||||
import me.grishka.appkit.api.ErrorResponse;
|
import me.grishka.appkit.api.ErrorResponse;
|
||||||
|
@ -56,13 +53,14 @@ public class AccountSessionManager{
|
||||||
|
|
||||||
private HashMap<String, AccountSession> sessions=new HashMap<>();
|
private HashMap<String, AccountSession> sessions=new HashMap<>();
|
||||||
private HashMap<String, List<EmojiCategory>> customEmojis=new HashMap<>();
|
private HashMap<String, List<EmojiCategory>> customEmojis=new HashMap<>();
|
||||||
private HashMap<String, Long> customEmojisLastUpdated=new HashMap<>();
|
private HashMap<String, Long> instancesLastUpdated=new HashMap<>();
|
||||||
|
private HashMap<String, Instance> instances=new HashMap<>();
|
||||||
private MastodonAPIController unauthenticatedApiController=new MastodonAPIController(null);
|
private MastodonAPIController unauthenticatedApiController=new MastodonAPIController(null);
|
||||||
private Instance authenticatingInstance;
|
private Instance authenticatingInstance;
|
||||||
private Application authenticatingApp;
|
private Application authenticatingApp;
|
||||||
private String lastActiveAccountID;
|
private String lastActiveAccountID;
|
||||||
private SharedPreferences prefs;
|
private SharedPreferences prefs;
|
||||||
private boolean loadedCustomEmojis;
|
private boolean loadedInstances;
|
||||||
|
|
||||||
public static AccountSessionManager getInstance(){
|
public static AccountSessionManager getInstance(){
|
||||||
return instance;
|
return instance;
|
||||||
|
@ -84,14 +82,16 @@ public class AccountSessionManager{
|
||||||
Log.e(TAG, "Error loading accounts", x);
|
Log.e(TAG, "Error loading accounts", x);
|
||||||
}
|
}
|
||||||
lastActiveAccountID=prefs.getString("lastActiveAccount", null);
|
lastActiveAccountID=prefs.getString("lastActiveAccount", null);
|
||||||
MastodonAPIController.runInBackground(()->readCustomEmojis(domains));
|
MastodonAPIController.runInBackground(()->readInstanceInfo(domains));
|
||||||
}
|
}
|
||||||
|
|
||||||
public void addAccount(Instance instance, Token token, Account self, Application app, boolean active){
|
public void addAccount(Instance instance, Token token, Account self, Application app, boolean active){
|
||||||
AccountSession session=new AccountSession(token, self, app, instance.uri, instance.maxTootChars, instance, active);
|
instances.put(instance.uri, instance);
|
||||||
|
AccountSession session=new AccountSession(token, self, app, instance.uri, active);
|
||||||
sessions.put(session.getID(), session);
|
sessions.put(session.getID(), session);
|
||||||
lastActiveAccountID=session.getID();
|
lastActiveAccountID=session.getID();
|
||||||
writeAccountsFile();
|
writeAccountsFile();
|
||||||
|
maybeUpdateLocalInfo();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void writeAccountsFile(){
|
private void writeAccountsFile(){
|
||||||
|
@ -155,7 +155,7 @@ public class AccountSessionManager{
|
||||||
writeAccountsFile();
|
writeAccountsFile();
|
||||||
String domain=session.domain.toLowerCase();
|
String domain=session.domain.toLowerCase();
|
||||||
if(sessions.isEmpty() || !sessions.values().stream().map(s->s.domain.toLowerCase()).collect(Collectors.toSet()).contains(domain)){
|
if(sessions.isEmpty() || !sessions.values().stream().map(s->s.domain.toLowerCase()).collect(Collectors.toSet()).contains(domain)){
|
||||||
getCustomEmojisFile(domain).delete();
|
getInstanceInfoFile(domain).delete();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -213,11 +213,11 @@ public class AccountSessionManager{
|
||||||
HashSet<String> domains=new HashSet<>();
|
HashSet<String> domains=new HashSet<>();
|
||||||
for(AccountSession session:sessions.values()){
|
for(AccountSession session:sessions.values()){
|
||||||
domains.add(session.domain.toLowerCase());
|
domains.add(session.domain.toLowerCase());
|
||||||
if(now-session.infoLastUpdated>24L*3600_000L || now-session.instanceLastUpdated>24L*360_000L*3L){
|
if(now-session.infoLastUpdated>24L*3600_000L){
|
||||||
updateSessionLocalInfo(session);
|
updateSessionLocalInfo(session);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if(loadedCustomEmojis){
|
if(loadedInstances){
|
||||||
maybeUpdateCustomEmojis(domains);
|
maybeUpdateCustomEmojis(domains);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -225,9 +225,9 @@ public class AccountSessionManager{
|
||||||
private void maybeUpdateCustomEmojis(Set<String> domains){
|
private void maybeUpdateCustomEmojis(Set<String> domains){
|
||||||
long now=System.currentTimeMillis();
|
long now=System.currentTimeMillis();
|
||||||
for(String domain:domains){
|
for(String domain:domains){
|
||||||
Long lastUpdated=customEmojisLastUpdated.get(domain);
|
Long lastUpdated=instancesLastUpdated.get(domain);
|
||||||
if(lastUpdated==null || now-lastUpdated>24L*3600_000L){
|
if(lastUpdated==null || now-lastUpdated>24L*3600_000L){
|
||||||
updateCustomEmojis(domain);
|
updateInstanceInfo(domain);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -248,34 +248,33 @@ public class AccountSessionManager{
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.exec(session.getID());
|
.exec(session.getID());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void updateInstanceInfo(String domain){
|
||||||
new GetInstance()
|
new GetInstance()
|
||||||
.setCallback(new Callback<>(){
|
.setCallback(new Callback<>(){
|
||||||
@Override
|
@Override
|
||||||
public void onSuccess(Instance result){
|
public void onSuccess(Instance instance){
|
||||||
session.instance=result;
|
instances.put(domain, instance);
|
||||||
session.instanceLastUpdated=System.currentTimeMillis();
|
new GetCustomEmojis()
|
||||||
writeAccountsFile();
|
.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));
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onError(ErrorResponse error){
|
public void onError(ErrorResponse error){
|
||||||
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.exec(session.getID());
|
.execNoAuth(domain);
|
||||||
}
|
|
||||||
|
|
||||||
private void updateCustomEmojis(String domain){
|
|
||||||
new GetCustomEmojis()
|
|
||||||
.setCallback(new Callback<>(){
|
|
||||||
@Override
|
|
||||||
public void onSuccess(List<Emoji> result){
|
|
||||||
CustomEmojisStorageWrapper emojis=new CustomEmojisStorageWrapper();
|
|
||||||
emojis.lastUpdated=System.currentTimeMillis();
|
|
||||||
emojis.emojis=result;
|
|
||||||
customEmojis.put(domain, groupCustomEmojis(emojis));
|
|
||||||
customEmojisLastUpdated.put(domain, emojis.lastUpdated);
|
|
||||||
MastodonAPIController.runInBackground(()->writeCustomEmojisFile(emojis, domain));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
@ -286,38 +285,39 @@ public class AccountSessionManager{
|
||||||
.execNoAuth(domain);
|
.execNoAuth(domain);
|
||||||
}
|
}
|
||||||
|
|
||||||
private File getCustomEmojisFile(String domain){
|
private File getInstanceInfoFile(String domain){
|
||||||
return new File(MastodonApp.context.getFilesDir(), "emojis_"+domain.replace('.', '_')+".json");
|
return new File(MastodonApp.context.getFilesDir(), "instance_"+domain.replace('.', '_')+".json");
|
||||||
}
|
}
|
||||||
|
|
||||||
private void writeCustomEmojisFile(CustomEmojisStorageWrapper emojis, String domain){
|
private void writeInstanceInfoFile(InstanceInfoStorageWrapper emojis, String domain){
|
||||||
try(FileOutputStream out=new FileOutputStream(getCustomEmojisFile(domain))){
|
try(FileOutputStream out=new FileOutputStream(getInstanceInfoFile(domain))){
|
||||||
OutputStreamWriter writer=new OutputStreamWriter(out, StandardCharsets.UTF_8);
|
OutputStreamWriter writer=new OutputStreamWriter(out, StandardCharsets.UTF_8);
|
||||||
MastodonAPIController.gson.toJson(emojis, writer);
|
MastodonAPIController.gson.toJson(emojis, writer);
|
||||||
writer.flush();
|
writer.flush();
|
||||||
}catch(IOException x){
|
}catch(IOException x){
|
||||||
Log.w(TAG, "Error writing emojis file for "+domain, x);
|
Log.w(TAG, "Error writing instance info file for "+domain, x);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void readCustomEmojis(Set<String> domains){
|
private void readInstanceInfo(Set<String> domains){
|
||||||
for(String domain:domains){
|
for(String domain:domains){
|
||||||
try(FileInputStream in=new FileInputStream(getCustomEmojisFile(domain))){
|
try(FileInputStream in=new FileInputStream(getInstanceInfoFile(domain))){
|
||||||
InputStreamReader reader=new InputStreamReader(in, StandardCharsets.UTF_8);
|
InputStreamReader reader=new InputStreamReader(in, StandardCharsets.UTF_8);
|
||||||
CustomEmojisStorageWrapper emojis=MastodonAPIController.gson.fromJson(reader, CustomEmojisStorageWrapper.class);
|
InstanceInfoStorageWrapper emojis=MastodonAPIController.gson.fromJson(reader, InstanceInfoStorageWrapper.class);
|
||||||
customEmojis.put(domain, groupCustomEmojis(emojis));
|
customEmojis.put(domain, groupCustomEmojis(emojis));
|
||||||
customEmojisLastUpdated.put(domain, emojis.lastUpdated);
|
instances.put(domain, emojis.instance);
|
||||||
|
instancesLastUpdated.put(domain, emojis.lastUpdated);
|
||||||
}catch(IOException|JsonParseException x){
|
}catch(IOException|JsonParseException x){
|
||||||
Log.w(TAG, "Error reading emojis file for "+domain, x);
|
Log.w(TAG, "Error reading instance info file for "+domain, x);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if(!loadedCustomEmojis){
|
if(!loadedInstances){
|
||||||
loadedCustomEmojis=true;
|
loadedInstances=true;
|
||||||
maybeUpdateCustomEmojis(domains);
|
maybeUpdateCustomEmojis(domains);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<EmojiCategory> groupCustomEmojis(CustomEmojisStorageWrapper emojis){
|
private List<EmojiCategory> groupCustomEmojis(InstanceInfoStorageWrapper emojis){
|
||||||
return emojis.emojis.stream()
|
return emojis.emojis.stream()
|
||||||
.filter(e->e.visibleInPicker)
|
.filter(e->e.visibleInPicker)
|
||||||
.collect(Collectors.groupingBy(e->e.category==null ? "" : e.category))
|
.collect(Collectors.groupingBy(e->e.category==null ? "" : e.category))
|
||||||
|
@ -333,6 +333,10 @@ public class AccountSessionManager{
|
||||||
return r==null ? Collections.emptyList() : r;
|
return r==null ? Collections.emptyList() : r;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Instance getInstanceInfo(String domain){
|
||||||
|
return instances.get(domain);
|
||||||
|
}
|
||||||
|
|
||||||
public void updateAccountInfo(String id, Account account){
|
public void updateAccountInfo(String id, Account account){
|
||||||
AccountSession session=getAccount(id);
|
AccountSession session=getAccount(id);
|
||||||
session.self=account;
|
session.self=account;
|
||||||
|
@ -344,7 +348,8 @@ public class AccountSessionManager{
|
||||||
public List<AccountSession> accounts;
|
public List<AccountSession> accounts;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static class CustomEmojisStorageWrapper{
|
private static class InstanceInfoStorageWrapper{
|
||||||
|
public Instance instance;
|
||||||
public List<Emoji> emojis;
|
public List<Emoji> emojis;
|
||||||
public long lastUpdated;
|
public long lastUpdated;
|
||||||
}
|
}
|
||||||
|
|
|
@ -18,6 +18,7 @@ import android.os.Build;
|
||||||
import android.os.Bundle;
|
import android.os.Bundle;
|
||||||
import android.os.Parcelable;
|
import android.os.Parcelable;
|
||||||
import android.text.Editable;
|
import android.text.Editable;
|
||||||
|
import android.text.InputFilter;
|
||||||
import android.text.Layout;
|
import android.text.Layout;
|
||||||
import android.text.SpannableStringBuilder;
|
import android.text.SpannableStringBuilder;
|
||||||
import android.text.Spanned;
|
import android.text.Spanned;
|
||||||
|
@ -62,6 +63,7 @@ import org.joinmastodon.android.model.Account;
|
||||||
import org.joinmastodon.android.model.Attachment;
|
import org.joinmastodon.android.model.Attachment;
|
||||||
import org.joinmastodon.android.model.Emoji;
|
import org.joinmastodon.android.model.Emoji;
|
||||||
import org.joinmastodon.android.model.EmojiCategory;
|
import org.joinmastodon.android.model.EmojiCategory;
|
||||||
|
import org.joinmastodon.android.model.Instance;
|
||||||
import org.joinmastodon.android.model.Mention;
|
import org.joinmastodon.android.model.Mention;
|
||||||
import org.joinmastodon.android.model.Status;
|
import org.joinmastodon.android.model.Status;
|
||||||
import org.joinmastodon.android.model.StatusPrivacy;
|
import org.joinmastodon.android.model.StatusPrivacy;
|
||||||
|
@ -104,7 +106,6 @@ public class ComposeFragment extends ToolbarFragment implements OnBackPressedLis
|
||||||
|
|
||||||
private static final int MEDIA_RESULT=717;
|
private static final int MEDIA_RESULT=717;
|
||||||
private static final int IMAGE_DESCRIPTION_RESULT=363;
|
private static final int IMAGE_DESCRIPTION_RESULT=363;
|
||||||
private static final int MAX_POLL_OPTIONS=4;
|
|
||||||
private static final int MAX_ATTACHMENTS=4;
|
private static final int MAX_ATTACHMENTS=4;
|
||||||
|
|
||||||
private static final Pattern MENTION_PATTERN=Pattern.compile("(^|[^\\/\\w])@(([a-z0-9_]+)@[a-z0-9\\.\\-]+[a-z0-9]+)", Pattern.CASE_INSENSITIVE);
|
private static final Pattern MENTION_PATTERN=Pattern.compile("(^|[^\\/\\w])@(([a-z0-9_]+)@[a-z0-9\\.\\-]+[a-z0-9]+)", Pattern.CASE_INSENSITIVE);
|
||||||
|
@ -173,6 +174,7 @@ public class ComposeFragment extends ToolbarFragment implements OnBackPressedLis
|
||||||
private ComposeAutocompleteSpan currentAutocompleteSpan;
|
private ComposeAutocompleteSpan currentAutocompleteSpan;
|
||||||
private FrameLayout mainEditTextWrap;
|
private FrameLayout mainEditTextWrap;
|
||||||
private ComposeAutocompleteViewController autocompleteViewController;
|
private ComposeAutocompleteViewController autocompleteViewController;
|
||||||
|
private Instance instance;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onCreate(Bundle savedInstanceState){
|
public void onCreate(Bundle savedInstanceState){
|
||||||
|
@ -181,12 +183,22 @@ public class ComposeFragment extends ToolbarFragment implements OnBackPressedLis
|
||||||
|
|
||||||
accountID=getArguments().getString("account");
|
accountID=getArguments().getString("account");
|
||||||
AccountSession session=AccountSessionManager.getInstance().getAccount(accountID);
|
AccountSession session=AccountSessionManager.getInstance().getAccount(accountID);
|
||||||
charLimit=session.tootCharLimit;
|
|
||||||
if(charLimit==0)
|
|
||||||
charLimit=500;
|
|
||||||
self=session.self;
|
self=session.self;
|
||||||
instanceDomain=session.domain;
|
instanceDomain=session.domain;
|
||||||
customEmojis=AccountSessionManager.getInstance().getCustomEmojis(instanceDomain);
|
customEmojis=AccountSessionManager.getInstance().getCustomEmojis(instanceDomain);
|
||||||
|
instance=AccountSessionManager.getInstance().getInstanceInfo(instanceDomain);
|
||||||
|
if(instance==null){
|
||||||
|
Nav.finish(this);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if(instance.maxTootChars>0)
|
||||||
|
charLimit=instance.maxTootChars;
|
||||||
|
else if(instance.configuration!=null && instance.configuration.statuses!=null && instance.configuration.statuses.maxCharacters>0)
|
||||||
|
charLimit=instance.configuration.statuses.maxCharacters;
|
||||||
|
else
|
||||||
|
charLimit=500;
|
||||||
|
|
||||||
if(getArguments().containsKey("replyTo")){
|
if(getArguments().containsKey("replyTo")){
|
||||||
replyTo=Parcels.unwrap(getArguments().getParcelable("replyTo"));
|
replyTo=Parcels.unwrap(getArguments().getParcelable("replyTo"));
|
||||||
statusVisibility=replyTo.visibility;
|
statusVisibility=replyTo.visibility;
|
||||||
|
@ -647,7 +659,11 @@ public class ComposeFragment extends ToolbarFragment implements OnBackPressedLis
|
||||||
Intent intent=new Intent(Intent.ACTION_GET_CONTENT);
|
Intent intent=new Intent(Intent.ACTION_GET_CONTENT);
|
||||||
intent.addCategory(Intent.CATEGORY_OPENABLE);
|
intent.addCategory(Intent.CATEGORY_OPENABLE);
|
||||||
intent.setType("*/*");
|
intent.setType("*/*");
|
||||||
intent.putExtra(Intent.EXTRA_MIME_TYPES, new String[]{"image/*", "video/*"});
|
if(instance.configuration!=null && instance.configuration.mediaAttachments!=null && instance.configuration.mediaAttachments.supportedMimeTypes!=null && !instance.configuration.mediaAttachments.supportedMimeTypes.isEmpty()){
|
||||||
|
intent.putExtra(Intent.EXTRA_MIME_TYPES, instance.configuration.mediaAttachments.supportedMimeTypes.toArray(new String[0]));
|
||||||
|
}else{
|
||||||
|
intent.putExtra(Intent.EXTRA_MIME_TYPES, new String[]{"image/*", "video/*"});
|
||||||
|
}
|
||||||
intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true);
|
intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true);
|
||||||
startActivityForResult(intent, MEDIA_RESULT);
|
startActivityForResult(intent, MEDIA_RESULT);
|
||||||
}
|
}
|
||||||
|
@ -740,7 +756,12 @@ public class ComposeFragment extends ToolbarFragment implements OnBackPressedLis
|
||||||
rotationAnimator.setDuration(1500);
|
rotationAnimator.setDuration(1500);
|
||||||
rotationAnimator.setRepeatCount(ObjectAnimator.INFINITE);
|
rotationAnimator.setRepeatCount(ObjectAnimator.INFINITE);
|
||||||
rotationAnimator.start();
|
rotationAnimator.start();
|
||||||
attachment.uploadRequest=(UploadAttachment) new UploadAttachment(attachment.uri)
|
int maxSize=0;
|
||||||
|
String contentType=getActivity().getContentResolver().getType(attachment.uri);
|
||||||
|
if(contentType!=null && contentType.startsWith("image/")){
|
||||||
|
maxSize=2_073_600; // TODO get this from instance configuration when it gets added there
|
||||||
|
}
|
||||||
|
attachment.uploadRequest=(UploadAttachment) new UploadAttachment(attachment.uri, maxSize)
|
||||||
.setProgressListener(new ProgressListener(){
|
.setProgressListener(new ProgressListener(){
|
||||||
@Override
|
@Override
|
||||||
public void onProgress(long transferred, long total){
|
public void onProgress(long transferred, long total){
|
||||||
|
@ -864,10 +885,11 @@ public class ComposeFragment extends ToolbarFragment implements OnBackPressedLis
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
option.edit.addTextChangedListener(new SimpleTextWatcher(e->updatePublishButtonState()));
|
option.edit.addTextChangedListener(new SimpleTextWatcher(e->updatePublishButtonState()));
|
||||||
|
option.edit.setFilters(new InputFilter[]{new InputFilter.LengthFilter(instance.configuration!=null && instance.configuration.polls!=null && instance.configuration.polls.maxCharactersPerOption>0 ? instance.configuration.polls.maxCharactersPerOption : 50)});
|
||||||
|
|
||||||
pollOptionsView.addView(option.view);
|
pollOptionsView.addView(option.view);
|
||||||
pollOptions.add(option);
|
pollOptions.add(option);
|
||||||
if(pollOptions.size()==MAX_POLL_OPTIONS)
|
if(pollOptions.size()==(instance.configuration!=null && instance.configuration.polls!=null && instance.configuration.polls.maxOptions>0 ? instance.configuration.polls.maxOptions : 4))
|
||||||
addPollOptionBtn.setVisibility(View.GONE);
|
addPollOptionBtn.setVisibility(View.GONE);
|
||||||
return option;
|
return option;
|
||||||
}
|
}
|
||||||
|
|
|
@ -132,7 +132,7 @@ public class AccountActivationFragment extends AppKitFragment{
|
||||||
AccountSessionManager mgr=AccountSessionManager.getInstance();
|
AccountSessionManager mgr=AccountSessionManager.getInstance();
|
||||||
AccountSession session=mgr.getAccount(accountID);
|
AccountSession session=mgr.getAccount(accountID);
|
||||||
mgr.removeAccount(accountID);
|
mgr.removeAccount(accountID);
|
||||||
mgr.addAccount(session.instance, session.token, result, session.app, true);
|
mgr.addAccount(mgr.getInstanceInfo(session.domain), session.token, result, session.app, true);
|
||||||
String newID=mgr.getLastActiveAccountID();
|
String newID=mgr.getLastActiveAccountID();
|
||||||
Bundle args=new Bundle();
|
Bundle args=new Bundle();
|
||||||
args.putString("account", newID);
|
args.putString("account", newID);
|
||||||
|
|
|
@ -21,7 +21,7 @@ public class ReportRuleChoiceFragment extends BaseReportChoiceFragment{
|
||||||
@Override
|
@Override
|
||||||
protected void populateItems(){
|
protected void populateItems(){
|
||||||
isMultipleChoice=true;
|
isMultipleChoice=true;
|
||||||
Instance inst=AccountSessionManager.getInstance().getAccount(accountID).instance;
|
Instance inst=AccountSessionManager.getInstance().getInstanceInfo(AccountSessionManager.getInstance().getAccount(accountID).domain);
|
||||||
if(inst!=null && inst.rules!=null){
|
if(inst!=null && inst.rules!=null){
|
||||||
for(Instance.Rule rule:inst.rules){
|
for(Instance.Rule rule:inst.rules){
|
||||||
items.add(new Item(rule.text, null, rule.id));
|
items.add(new Item(rule.text, null, rule.id));
|
||||||
|
|
|
@ -76,8 +76,11 @@ public class Instance extends BaseModel{
|
||||||
public Account contactAccount;
|
public Account contactAccount;
|
||||||
public Stats stats;
|
public Stats stats;
|
||||||
|
|
||||||
public int maxTootChars;
|
|
||||||
public List<Rule> rules;
|
public List<Rule> rules;
|
||||||
|
public Configuration configuration;
|
||||||
|
|
||||||
|
// non-standard field in some Mastodon forks
|
||||||
|
public int maxTootChars;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void postprocess() throws ObjectValidationException{
|
public void postprocess() throws ObjectValidationException{
|
||||||
|
@ -137,4 +140,36 @@ public class Instance extends BaseModel{
|
||||||
public int statusCount;
|
public int statusCount;
|
||||||
public int domainCount;
|
public int domainCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Parcel
|
||||||
|
public static class Configuration{
|
||||||
|
public StatusesConfiguration statuses;
|
||||||
|
public MediaAttachmentsConfiguration mediaAttachments;
|
||||||
|
public PollsConfiguration polls;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Parcel
|
||||||
|
public static class StatusesConfiguration{
|
||||||
|
public int maxCharacters;
|
||||||
|
public int maxMediaAttachments;
|
||||||
|
public int charactersReservedPerUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Parcel
|
||||||
|
public static class MediaAttachmentsConfiguration{
|
||||||
|
public List<String> supportedMimeTypes;
|
||||||
|
public int imageSizeLimit;
|
||||||
|
public int imageMatrixLimit;
|
||||||
|
public int videoSizeLimit;
|
||||||
|
public int videoFrameRateLimit;
|
||||||
|
public int videoMatrixLimit;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Parcel
|
||||||
|
public static class PollsConfiguration{
|
||||||
|
public int maxOptions;
|
||||||
|
public int maxCharactersPerOption;
|
||||||
|
public int minExpiration;
|
||||||
|
public int maxExpiration;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue