mirror of https://github.com/tuskyapp/Tusky.git
Browse Source
* refactor compose & announcements to coroutines * fix code formatting * add javadoc to InstanceInfoRepository * fix comments in ImageDownsizer * remove unused Either extensions * add explicit return type for InstanceInfoRepository.getEmojis * make ComposeViewModel.pickMedia return Result * cleanup code in ImageDownsizerpull/2443/head
15 changed files with 589 additions and 621 deletions
@ -1,154 +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.components.compose; |
||||
|
||||
import android.content.ContentResolver; |
||||
import android.graphics.Bitmap; |
||||
import android.graphics.BitmapFactory; |
||||
import android.net.Uri; |
||||
import android.os.AsyncTask; |
||||
|
||||
import com.keylesspalace.tusky.util.IOUtils; |
||||
|
||||
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) { |
||||
boolean result = DownsizeImageTask.resize(uris, sizeLimit, contentResolver, tempFile); |
||||
if (isCancelled()) { |
||||
return false; |
||||
} |
||||
return result; |
||||
} |
||||
|
||||
@Override |
||||
protected void onPostExecute(Boolean successful) { |
||||
if (successful) { |
||||
listener.onSuccess(tempFile); |
||||
} else { |
||||
listener.onFailure(); |
||||
} |
||||
super.onPostExecute(successful); |
||||
} |
||||
|
||||
public static boolean resize(Uri[] uris, int sizeLimit, ContentResolver contentResolver, |
||||
File tempFile) { |
||||
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); |
||||
} |
||||
return true; |
||||
} |
||||
|
||||
/** |
||||
* Used to communicate the results of the task. |
||||
*/ |
||||
public interface Listener { |
||||
void onSuccess(File file); |
||||
|
||||
void onFailure(); |
||||
} |
||||
} |
||||
@ -0,0 +1,101 @@
|
||||
/* Copyright 2022 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.components.compose |
||||
|
||||
import android.content.ContentResolver |
||||
import android.graphics.Bitmap |
||||
import android.graphics.Bitmap.CompressFormat |
||||
import android.graphics.BitmapFactory |
||||
import android.net.Uri |
||||
import com.keylesspalace.tusky.util.IOUtils |
||||
import com.keylesspalace.tusky.util.calculateInSampleSize |
||||
import com.keylesspalace.tusky.util.getImageOrientation |
||||
import com.keylesspalace.tusky.util.reorientBitmap |
||||
import java.io.File |
||||
import java.io.FileNotFoundException |
||||
import java.io.FileOutputStream |
||||
|
||||
/** |
||||
* @param uri the uri pointing to the input file |
||||
* @param sizeLimit the maximum number of bytes the output image is allowed to have |
||||
* @param contentResolver to resolve the specified input uri |
||||
* @param tempFile the file where the result will be stored |
||||
* @return true when the image was successfully resized, false otherwise |
||||
*/ |
||||
fun downsizeImage( |
||||
uri: Uri, |
||||
sizeLimit: Int, |
||||
contentResolver: ContentResolver, |
||||
tempFile: File |
||||
): Boolean { |
||||
|
||||
val decodeBoundsInputStream = try { |
||||
contentResolver.openInputStream(uri) |
||||
} catch (e: FileNotFoundException) { |
||||
return false |
||||
} |
||||
// Initially, just get the image dimensions. |
||||
val options = BitmapFactory.Options() |
||||
options.inJustDecodeBounds = true |
||||
BitmapFactory.decodeStream(decodeBoundsInputStream, null, options) |
||||
IOUtils.closeQuietly(decodeBoundsInputStream) |
||||
// Get EXIF data, for orientation info. |
||||
val 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. */ |
||||
var scaledImageSize = 1024 |
||||
do { |
||||
val outputStream = try { |
||||
FileOutputStream(tempFile) |
||||
} catch (e: FileNotFoundException) { |
||||
return false |
||||
} |
||||
val decodeBitmapInputStream = try { |
||||
contentResolver.openInputStream(uri) |
||||
} catch (e: FileNotFoundException) { |
||||
return false |
||||
} |
||||
options.inSampleSize = calculateInSampleSize(options, scaledImageSize, scaledImageSize) |
||||
options.inJustDecodeBounds = false |
||||
val scaledBitmap: Bitmap = try { |
||||
BitmapFactory.decodeStream(decodeBitmapInputStream, null, options) |
||||
} catch (error: OutOfMemoryError) { |
||||
return false |
||||
} finally { |
||||
IOUtils.closeQuietly(decodeBitmapInputStream) |
||||
} ?: return false |
||||
|
||||
val reorientedBitmap = reorientBitmap(scaledBitmap, orientation) |
||||
if (reorientedBitmap == null) { |
||||
scaledBitmap.recycle() |
||||
return false |
||||
} |
||||
/* Retain transparency if there is any by encoding as png */ |
||||
val format: CompressFormat = if (!reorientedBitmap.hasAlpha()) { |
||||
CompressFormat.JPEG |
||||
} else { |
||||
CompressFormat.PNG |
||||
} |
||||
reorientedBitmap.compress(format, 85, outputStream) |
||||
reorientedBitmap.recycle() |
||||
scaledImageSize /= 2 |
||||
} while (tempFile.length() > sizeLimit) |
||||
|
||||
return true |
||||
} |
||||
@ -0,0 +1,26 @@
|
||||
/* Copyright 2022 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.components.instanceinfo |
||||
|
||||
data class InstanceInfo( |
||||
val maxChars: Int, |
||||
val pollMaxOptions: Int, |
||||
val pollMaxLength: Int, |
||||
val pollMinDuration: Int, |
||||
val pollMaxDuration: Int, |
||||
val charactersReservedPerUrl: Int, |
||||
val supportsScheduled: Boolean |
||||
) |
||||
@ -0,0 +1,104 @@
|
||||
/* Copyright 2022 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.components.instanceinfo |
||||
|
||||
import android.util.Log |
||||
import com.keylesspalace.tusky.db.AccountManager |
||||
import com.keylesspalace.tusky.db.AppDatabase |
||||
import com.keylesspalace.tusky.db.EmojisEntity |
||||
import com.keylesspalace.tusky.db.InstanceInfoEntity |
||||
import com.keylesspalace.tusky.entity.Emoji |
||||
import com.keylesspalace.tusky.network.MastodonApi |
||||
import com.keylesspalace.tusky.util.VersionUtils |
||||
import kotlinx.coroutines.Dispatchers |
||||
import kotlinx.coroutines.withContext |
||||
import javax.inject.Inject |
||||
|
||||
class InstanceInfoRepository @Inject constructor( |
||||
private val api: MastodonApi, |
||||
db: AppDatabase, |
||||
accountManager: AccountManager |
||||
) { |
||||
|
||||
private val dao = db.instanceDao() |
||||
private val instanceName = accountManager.activeAccount!!.domain |
||||
|
||||
/** |
||||
* Returns the custom emojis of the instance. |
||||
* Will always try to fetch them from the api, falls back to cached Emojis in case it is not available. |
||||
* Never throws, returns empty list in case of error. |
||||
*/ |
||||
suspend fun getEmojis(): List<Emoji> = withContext(Dispatchers.IO) { |
||||
api.getCustomEmojis() |
||||
.onSuccess { emojiList -> dao.insertOrReplace(EmojisEntity(instanceName, emojiList)) } |
||||
.getOrElse { throwable -> |
||||
Log.w(TAG, "failed to load custom emojis, falling back to cache", throwable) |
||||
dao.getEmojiInfo(instanceName)?.emojiList.orEmpty() |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Returns information about the instance. |
||||
* Will always try to fetch the most up-to-date data from the api, falls back to cache in case it is not available. |
||||
* Never throws, returns defaults of vanilla Mastodon in case of error. |
||||
*/ |
||||
suspend fun getInstanceInfo(): InstanceInfo = withContext(Dispatchers.IO) { |
||||
api.getInstance() |
||||
.fold( |
||||
{ instance -> |
||||
val instanceEntity = InstanceInfoEntity( |
||||
instance = instanceName, |
||||
maximumTootCharacters = instance.configuration?.statuses?.maxCharacters ?: instance.maxTootChars, |
||||
maxPollOptions = instance.configuration?.polls?.maxOptions ?: instance.pollConfiguration?.maxOptions, |
||||
maxPollOptionLength = instance.configuration?.polls?.maxCharactersPerOption ?: instance.pollConfiguration?.maxOptionChars, |
||||
minPollDuration = instance.configuration?.polls?.minExpiration ?: instance.pollConfiguration?.minExpiration, |
||||
maxPollDuration = instance.configuration?.polls?.maxExpiration ?: instance.pollConfiguration?.maxExpiration, |
||||
charactersReservedPerUrl = instance.configuration?.statuses?.charactersReservedPerUrl, |
||||
version = instance.version |
||||
) |
||||
dao.insertOrReplace(instanceEntity) |
||||
instanceEntity |
||||
}, |
||||
{ throwable -> |
||||
Log.w(TAG, "failed to instance, falling back to cache and default values", throwable) |
||||
dao.getInstanceInfo(instanceName) |
||||
} |
||||
).let { instanceInfo: InstanceInfoEntity? -> |
||||
InstanceInfo( |
||||
maxChars = instanceInfo?.maximumTootCharacters ?: DEFAULT_CHARACTER_LIMIT, |
||||
pollMaxOptions = instanceInfo?.maxPollOptions ?: DEFAULT_MAX_OPTION_COUNT, |
||||
pollMaxLength = instanceInfo?.maxPollOptionLength ?: DEFAULT_MAX_OPTION_LENGTH, |
||||
pollMinDuration = instanceInfo?.minPollDuration ?: DEFAULT_MIN_POLL_DURATION, |
||||
pollMaxDuration = instanceInfo?.maxPollDuration ?: DEFAULT_MAX_POLL_DURATION, |
||||
charactersReservedPerUrl = instanceInfo?.charactersReservedPerUrl ?: DEFAULT_CHARACTERS_RESERVED_PER_URL, |
||||
supportsScheduled = instanceInfo?.version?.let { VersionUtils(it).supportsScheduledToots() } ?: false |
||||
) |
||||
} |
||||
} |
||||
|
||||
companion object { |
||||
private const val TAG = "InstanceInfoRepo" |
||||
|
||||
const val DEFAULT_CHARACTER_LIMIT = 500 |
||||
private const val DEFAULT_MAX_OPTION_COUNT = 4 |
||||
private const val DEFAULT_MAX_OPTION_LENGTH = 50 |
||||
private const val DEFAULT_MIN_POLL_DURATION = 300 |
||||
private const val DEFAULT_MAX_POLL_DURATION = 604800 |
||||
|
||||
// Mastodon only counts URLs as this long in terms of status character limits |
||||
const val DEFAULT_CHARACTERS_RESERVED_PER_URL = 23 |
||||
} |
||||
} |
||||
Loading…
Reference in new issue