ComposeActivity refactor (#1541)
* Convert ComposeActivity to Kotlin * More ComposeActivity cleanups * Move ComposeActivity to it's own package * Remove ComposeActivity.IntentBuilder * Re-do part of the media downsizing/uploading * Add sending of status to ViewModel, draft media descriptions * Allow uploading video, update description after uploading * Enable camera, enable upload cancelling * Cleanup of ComposeActivity * Extract CaptionDialog, extract ComposeActivity methods * Fix handling of redrafted media * Add initial state and media uploading out of Activity * Change ComposeOptions.mentionedUsernames to be Set rather than List We probably don't want repeated usernames when we are writing a post and Set provides such guarantee for free plus it tells it to the callers. The only disadvantage is lack of order but it shouldn't be a problem. * Add combineOptionalLiveData. Add docs. It it useful for nullable LiveData's. I think we cannot differentiate between value not being set and value being null so I just added the variant without null check. * Add poll support to Compose. * cleanup code * move more classes into compose package * cleanup code * fix button behavior * add error handling for media upload * add caching for instance data again * merge develop * fix scheduled toots * delete unused string * cleanup ComposeActivity * fix restoring media from drafts * make media upload code a little bit clearer * cleanup autocomplete search code * avoid duplicate object creation in SavedTootActivity * perf: avoid unnecessary work when initializing ComposeActivity * add license header to new files * use small toot button on bigger displays * fix ComposeActivityTest * fix bad merge * use Singles.zip instead of Single.zip
This commit is contained in:
parent
9457aa73b2
commit
8770fbe986
68 changed files with 3162 additions and 2666 deletions
|
@ -1,144 +0,0 @@
|
|||
/* Copyright 2017 Andrew Dawson
|
||||
*
|
||||
* This file is a part of Tusky.
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it under the terms of the
|
||||
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
|
||||
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
|
||||
* Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License along with Tusky; if not,
|
||||
* see <http://www.gnu.org/licenses>. */
|
||||
|
||||
package com.keylesspalace.tusky.util;
|
||||
|
||||
import android.content.ContentResolver;
|
||||
import android.graphics.Bitmap;
|
||||
import android.graphics.BitmapFactory;
|
||||
import android.net.Uri;
|
||||
import android.os.AsyncTask;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileNotFoundException;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
|
||||
import static com.keylesspalace.tusky.util.MediaUtilsKt.calculateInSampleSize;
|
||||
import static com.keylesspalace.tusky.util.MediaUtilsKt.getImageOrientation;
|
||||
import static com.keylesspalace.tusky.util.MediaUtilsKt.reorientBitmap;
|
||||
|
||||
/**
|
||||
* Reduces the file size of images to fit under a given limit by resizing them, maintaining both
|
||||
* aspect ratio and orientation.
|
||||
*/
|
||||
public class DownsizeImageTask extends AsyncTask<Uri, Void, Boolean> {
|
||||
private int sizeLimit;
|
||||
private ContentResolver contentResolver;
|
||||
private Listener listener;
|
||||
private File tempFile;
|
||||
|
||||
/**
|
||||
* @param sizeLimit the maximum number of bytes each image can take
|
||||
* @param contentResolver to resolve the specified images' URIs
|
||||
* @param tempFile the file where the result will be stored
|
||||
* @param listener to whom the results are given
|
||||
*/
|
||||
public DownsizeImageTask(int sizeLimit, ContentResolver contentResolver, File tempFile, Listener listener) {
|
||||
this.sizeLimit = sizeLimit;
|
||||
this.contentResolver = contentResolver;
|
||||
this.tempFile = tempFile;
|
||||
this.listener = listener;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Boolean doInBackground(Uri... uris) {
|
||||
for (Uri uri : uris) {
|
||||
InputStream inputStream;
|
||||
try {
|
||||
inputStream = contentResolver.openInputStream(uri);
|
||||
} catch (FileNotFoundException e) {
|
||||
return false;
|
||||
}
|
||||
// Initially, just get the image dimensions.
|
||||
BitmapFactory.Options options = new BitmapFactory.Options();
|
||||
options.inJustDecodeBounds = true;
|
||||
BitmapFactory.decodeStream(inputStream, null, options);
|
||||
IOUtils.closeQuietly(inputStream);
|
||||
// Get EXIF data, for orientation info.
|
||||
int orientation = getImageOrientation(uri, contentResolver);
|
||||
/* Unfortunately, there isn't a determined worst case compression ratio for image
|
||||
* formats. So, the only way to tell if they're too big is to compress them and
|
||||
* test, and keep trying at smaller sizes. The initial estimate should be good for
|
||||
* many cases, so it should only iterate once, but the loop is used to be absolutely
|
||||
* sure it gets downsized to below the limit. */
|
||||
int scaledImageSize = 1024;
|
||||
do {
|
||||
OutputStream stream;
|
||||
try {
|
||||
stream = new FileOutputStream(tempFile);
|
||||
} catch (FileNotFoundException e) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
inputStream = contentResolver.openInputStream(uri);
|
||||
} catch (FileNotFoundException e) {
|
||||
return false;
|
||||
}
|
||||
options.inSampleSize = calculateInSampleSize(options, scaledImageSize, scaledImageSize);
|
||||
options.inJustDecodeBounds = false;
|
||||
Bitmap scaledBitmap;
|
||||
try {
|
||||
scaledBitmap = BitmapFactory.decodeStream(inputStream, null, options);
|
||||
} catch (OutOfMemoryError error) {
|
||||
return false;
|
||||
} finally {
|
||||
IOUtils.closeQuietly(inputStream);
|
||||
}
|
||||
if (scaledBitmap == null) {
|
||||
return false;
|
||||
}
|
||||
Bitmap reorientedBitmap = reorientBitmap(scaledBitmap, orientation);
|
||||
if (reorientedBitmap == null) {
|
||||
scaledBitmap.recycle();
|
||||
return false;
|
||||
}
|
||||
Bitmap.CompressFormat format;
|
||||
/* It's not likely the user will give transparent images over the upload limit, but
|
||||
* if they do, make sure the transparency is retained. */
|
||||
if (!reorientedBitmap.hasAlpha()) {
|
||||
format = Bitmap.CompressFormat.JPEG;
|
||||
} else {
|
||||
format = Bitmap.CompressFormat.PNG;
|
||||
}
|
||||
reorientedBitmap.compress(format, 85, stream);
|
||||
reorientedBitmap.recycle();
|
||||
scaledImageSize /= 2;
|
||||
} while (tempFile.length() > sizeLimit);
|
||||
|
||||
if (isCancelled()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onPostExecute(Boolean successful) {
|
||||
if (successful) {
|
||||
listener.onSuccess(tempFile);
|
||||
} else {
|
||||
listener.onFailure();
|
||||
}
|
||||
super.onPostExecute(successful);
|
||||
}
|
||||
|
||||
/** Used to communicate the results of the task. */
|
||||
public interface Listener {
|
||||
void onSuccess(File file);
|
||||
void onFailure();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,93 @@
|
|||
/* Copyright 2019 Tusky Contributors
|
||||
*
|
||||
* This file is a part of Tusky.
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it under the terms of the
|
||||
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
|
||||
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
|
||||
* Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License along with Tusky; if not,
|
||||
* see <http://www.gnu.org/licenses>. */
|
||||
|
||||
package com.keylesspalace.tusky.util
|
||||
|
||||
import androidx.lifecycle.*
|
||||
import io.reactivex.BackpressureStrategy
|
||||
import io.reactivex.Observable
|
||||
import io.reactivex.Single
|
||||
|
||||
inline fun <X, Y> LiveData<X>.map(crossinline mapFunction: (X) -> Y): LiveData<Y> =
|
||||
Transformations.map(this) { input -> mapFunction(input) }
|
||||
|
||||
inline fun <X, Y> LiveData<X>.switchMap(
|
||||
crossinline switchMapFunction: (X) -> LiveData<Y>
|
||||
): LiveData<Y> = Transformations.switchMap(this) { input -> switchMapFunction(input) }
|
||||
|
||||
inline fun <X> LiveData<X>.filter(crossinline predicate: (X) -> Boolean): LiveData<X> {
|
||||
val liveData = MediatorLiveData<X>()
|
||||
liveData.addSource(this) { value ->
|
||||
if (predicate(value)) {
|
||||
liveData.value = value
|
||||
}
|
||||
}
|
||||
return liveData
|
||||
}
|
||||
|
||||
fun LifecycleOwner.withLifecycleContext(body: LifecycleContext.() -> Unit) =
|
||||
LifecycleContext(this).apply(body)
|
||||
|
||||
class LifecycleContext(val lifecycleOwner: LifecycleOwner) {
|
||||
inline fun <T> LiveData<T>.observe(crossinline observer: (T) -> Unit) =
|
||||
this.observe(lifecycleOwner, Observer { observer(it) })
|
||||
|
||||
/**
|
||||
* Just hold a subscription,
|
||||
*/
|
||||
fun <T> LiveData<T>.subscribe() =
|
||||
this.observe(lifecycleOwner, Observer { })
|
||||
}
|
||||
|
||||
/**
|
||||
* Invokes @param [combiner] when value of both @param [a] and @param [b] are not null. Returns
|
||||
* [LiveData] with value set to the result of calling [combiner] with value of both.
|
||||
* Important! You still need to observe to the returned [LiveData] for [combiner] to be invoked.
|
||||
*/
|
||||
fun <A, B, R> combineLiveData(a: LiveData<A>, b: LiveData<B>, combiner: (A, B) -> R): LiveData<R> {
|
||||
val liveData = MediatorLiveData<R>()
|
||||
liveData.addSource(a) {
|
||||
if (a.value != null && b.value != null) {
|
||||
liveData.value = combiner(a.value!!, b.value!!)
|
||||
}
|
||||
}
|
||||
liveData.addSource(b) {
|
||||
if (a.value != null && b.value != null) {
|
||||
liveData.value = combiner(a.value!!, b.value!!)
|
||||
}
|
||||
}
|
||||
return liveData
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns [LiveData] with value set to the result of calling [combiner] with value of [a] and [b]
|
||||
* after either changes. Doesn't check if either has value.
|
||||
* Important! You still need to observe to the returned [LiveData] for [combiner] to be invoked.
|
||||
*/
|
||||
fun <A, B, R> combineOptionalLiveData(a: LiveData<A>, b: LiveData<B>, combiner: (A?, B?) -> R): LiveData<R> {
|
||||
val liveData = MediatorLiveData<R>()
|
||||
liveData.addSource(a) {
|
||||
liveData.value = combiner(a.value, b.value)
|
||||
}
|
||||
liveData.addSource(b) {
|
||||
liveData.value = combiner(a.value, b.value)
|
||||
}
|
||||
return liveData
|
||||
}
|
||||
|
||||
fun <T> Single<T>.toLiveData() = LiveDataReactiveStreams.fromPublisher(this.toFlowable())
|
||||
fun <T> Observable<T>.toLiveData(
|
||||
backpressureStrategy: BackpressureStrategy = BackpressureStrategy.LATEST
|
||||
) = LiveDataReactiveStreams.fromPublisher(this.toFlowable(BackpressureStrategy.LATEST))
|
|
@ -5,16 +5,18 @@ import android.content.ContentResolver;
|
|||
import android.content.Context;
|
||||
import android.net.Uri;
|
||||
import android.os.AsyncTask;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.core.content.FileProvider;
|
||||
import android.text.TextUtils;
|
||||
import android.util.Log;
|
||||
import android.webkit.MimeTypeMap;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.core.content.FileProvider;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.reflect.TypeToken;
|
||||
import com.keylesspalace.tusky.BuildConfig;
|
||||
import com.keylesspalace.tusky.db.AppDatabase;
|
||||
import com.keylesspalace.tusky.db.TootDao;
|
||||
import com.keylesspalace.tusky.db.TootEntity;
|
||||
import com.keylesspalace.tusky.entity.NewPoll;
|
||||
|
@ -27,6 +29,8 @@ import java.util.Date;
|
|||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
import javax.inject.Inject;
|
||||
|
||||
public final class SaveTootHelper {
|
||||
|
||||
private static final String TAG = "SaveTootHelper";
|
||||
|
@ -35,15 +39,16 @@ public final class SaveTootHelper {
|
|||
private Context context;
|
||||
private Gson gson = new Gson();
|
||||
|
||||
public SaveTootHelper(@NonNull TootDao tootDao, @NonNull Context context) {
|
||||
this.tootDao = tootDao;
|
||||
@Inject
|
||||
public SaveTootHelper(@NonNull AppDatabase appDatabase, @NonNull Context context) {
|
||||
this.tootDao = appDatabase.tootDao();
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
@SuppressLint("StaticFieldLeak")
|
||||
public boolean saveToot(@NonNull String content,
|
||||
@NonNull String contentWarning,
|
||||
@Nullable String savedJsonUrls,
|
||||
@Nullable List<String> savedJsonUrls,
|
||||
@NonNull List<String> mediaUris,
|
||||
@NonNull List<String> mediaDescriptions,
|
||||
int savedTootUid,
|
||||
|
@ -58,31 +63,25 @@ public final class SaveTootHelper {
|
|||
}
|
||||
|
||||
// Get any existing file's URIs.
|
||||
ArrayList<String> existingUris = null;
|
||||
if (!TextUtils.isEmpty(savedJsonUrls)) {
|
||||
existingUris = gson.fromJson(savedJsonUrls,
|
||||
new TypeToken<ArrayList<String>>() {
|
||||
}.getType());
|
||||
}
|
||||
|
||||
String mediaUrlsSerialized = null;
|
||||
String mediaDescriptionsSerialized = null;
|
||||
|
||||
if (!ListUtils.isEmpty(mediaUris)) {
|
||||
List<String> savedList = saveMedia(mediaUris, existingUris);
|
||||
List<String> savedList = saveMedia(mediaUris, savedJsonUrls);
|
||||
if (!ListUtils.isEmpty(savedList)) {
|
||||
mediaUrlsSerialized = gson.toJson(savedList);
|
||||
if (!ListUtils.isEmpty(existingUris)) {
|
||||
deleteMedia(setDifference(existingUris, savedList));
|
||||
if (!ListUtils.isEmpty(savedJsonUrls)) {
|
||||
deleteMedia(setDifference(savedJsonUrls, savedList));
|
||||
}
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
mediaDescriptionsSerialized = gson.toJson(mediaDescriptions);
|
||||
} else if (!ListUtils.isEmpty(existingUris)) {
|
||||
} else if (!ListUtils.isEmpty(savedJsonUrls)) {
|
||||
/* If there were URIs in the previous draft, but they've now been removed, those files
|
||||
* can be deleted. */
|
||||
deleteMedia(existingUris);
|
||||
deleteMedia(savedJsonUrls);
|
||||
}
|
||||
final TootEntity toot = new TootEntity(savedTootUid, content, mediaUrlsSerialized, mediaDescriptionsSerialized, contentWarning,
|
||||
inReplyToId,
|
||||
|
@ -103,15 +102,16 @@ public final class SaveTootHelper {
|
|||
|
||||
public void deleteDraft(int tootId) {
|
||||
TootEntity item = tootDao.find(tootId);
|
||||
if(item != null) {
|
||||
if (item != null) {
|
||||
deleteDraft(item);
|
||||
}
|
||||
}
|
||||
|
||||
public void deleteDraft(@NonNull TootEntity item){
|
||||
public void deleteDraft(@NonNull TootEntity item) {
|
||||
// Delete any media files associated with the status.
|
||||
ArrayList<String> uris = gson.fromJson(item.getUrls(),
|
||||
new TypeToken<ArrayList<String>>() {}.getType());
|
||||
new TypeToken<ArrayList<String>>() {
|
||||
}.getType());
|
||||
if (uris != null) {
|
||||
for (String uriString : uris) {
|
||||
Uri uri = Uri.parse(uriString);
|
||||
|
@ -172,7 +172,7 @@ public final class SaveTootHelper {
|
|||
}
|
||||
return null;
|
||||
}
|
||||
Uri resultUri = FileProvider.getUriForFile(context, BuildConfig.APPLICATION_ID+".fileprovider", file);
|
||||
Uri resultUri = FileProvider.getUriForFile(context, BuildConfig.APPLICATION_ID + ".fileprovider", file);
|
||||
results.add(resultUri.toString());
|
||||
}
|
||||
return results;
|
||||
|
|
|
@ -51,4 +51,13 @@ inline fun EditText.onTextChanged(
|
|||
callback(s, start, before, count)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
inline fun EditText.afterTextChanged(
|
||||
crossinline callback: (s: Editable) -> Unit) {
|
||||
addTextChangedListener(object : DefaultTextWatcher() {
|
||||
override fun afterTextChanged(s: Editable) {
|
||||
callback(s)
|
||||
}
|
||||
})
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue