Enable sharing media directly from Tusky (#852)

* Extract duplicated code into BaseActivity

* Migrate MediaUtils to kotlin

* Migrate ViewVideoActivity to kotlin

* Migrate ViewMediaActivity to kotlin

* Initial media sharing functionality

* Address code review feedback

* Make share icon match

* Address code review feedback
This commit is contained in:
Levi Bard 2018-10-01 11:50:17 +02:00 committed by Konrad Pozniak
parent ab601c4566
commit 0bca94b94e
18 changed files with 785 additions and 690 deletions

View file

@ -15,19 +15,31 @@
package com.keylesspalace.tusky;
import android.Manifest;
import android.app.DownloadManager;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.content.pm.PackageManager;
import android.graphics.Color;
import android.graphics.PorterDuff;
import android.graphics.drawable.Drawable;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.os.Environment;
import android.preference.PreferenceManager;
import android.support.annotation.Nullable;
import android.support.annotation.StringRes;
import android.support.design.widget.Snackbar;
import android.support.v4.app.ActivityCompat;
import android.support.v4.content.ContextCompat;
import android.support.v7.app.AppCompatActivity;
import android.util.Log;
import android.util.TypedValue;
import android.view.Menu;
import android.view.View;
import android.widget.Toast;
import com.evernote.android.job.JobManager;
import com.evernote.android.job.JobRequest;
@ -36,6 +48,7 @@ import com.keylesspalace.tusky.db.AccountManager;
import com.keylesspalace.tusky.di.Injectable;
import com.keylesspalace.tusky.util.ThemeUtils;
import java.io.File;
import java.util.ArrayList;
import java.util.List;
@ -50,6 +63,8 @@ public abstract class BaseActivity extends AppCompatActivity implements Injectab
@Inject
public AccountManager accountManager;
protected static final int PERMISSIONS_REQUEST_WRITE_EXTERNAL_STORAGE = 1;
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
@ -177,6 +192,36 @@ public abstract class BaseActivity extends AppCompatActivity implements Injectab
.scheduleAsync();
}
protected void downloadFile(String url) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M &&
ContextCompat.checkSelfPermission(this, Manifest.permission.WRITE_EXTERNAL_STORAGE)
!= PackageManager.PERMISSION_GRANTED) {
ActivityCompat.requestPermissions(this,
new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE},
PERMISSIONS_REQUEST_WRITE_EXTERNAL_STORAGE);
} else {
String filename = new File(url).getName();
String toastText = String.format(getResources().getString(R.string.download_image), filename);
Toast.makeText(getApplicationContext(), toastText, Toast.LENGTH_SHORT).show();
DownloadManager downloadManager = (DownloadManager) getSystemService(Context.DOWNLOAD_SERVICE);
DownloadManager.Request request = new DownloadManager.Request(Uri.parse(url));
request.allowScanningByMediaScanner();
request.setDestinationInExternalPublicDir(Environment.DIRECTORY_PICTURES,
getString(R.string.app_name) + "/" + filename);
downloadManager.enqueue(request);
}
}
protected void showErrorDialog(View anyView, @StringRes int descriptionId, @StringRes int actionId, View.OnClickListener listener) {
if (anyView != null) {
Snackbar bar = Snackbar.make(anyView, getString(descriptionId), Snackbar.LENGTH_SHORT);
bar.setAction(actionId, listener);
bar.show();
}
}
@Override
protected void onDestroy() {
for (Call call : callList) {

View file

@ -102,7 +102,6 @@ import com.keylesspalace.tusky.service.SendTootService;
import com.keylesspalace.tusky.util.CountUpDownLatch;
import com.keylesspalace.tusky.util.DownsizeImageTask;
import com.keylesspalace.tusky.util.ListUtils;
import com.keylesspalace.tusky.util.MediaUtils;
import com.keylesspalace.tusky.util.MentionTokenizer;
import com.keylesspalace.tusky.util.SaveTootHelper;
import com.keylesspalace.tusky.util.SpanUtilsKt;
@ -146,6 +145,12 @@ import retrofit2.Call;
import retrofit2.Callback;
import retrofit2.Response;
import static com.keylesspalace.tusky.util.MediaUtilsKt.MEDIA_SIZE_UNKNOWN;
import static com.keylesspalace.tusky.util.MediaUtilsKt.getImageSquarePixels;
import static com.keylesspalace.tusky.util.MediaUtilsKt.getImageThumbnail;
import static com.keylesspalace.tusky.util.MediaUtilsKt.getMediaSize;
import static com.keylesspalace.tusky.util.MediaUtilsKt.getSampledBitmap;
import static com.keylesspalace.tusky.util.MediaUtilsKt.getVideoThumbnail;
import static com.uber.autodispose.AutoDispose.autoDisposable;
import static com.uber.autodispose.android.lifecycle.AndroidLifecycleScopeProvider.from;
@ -548,12 +553,12 @@ public final class ComposeActivity
if (!ListUtils.isEmpty(loadedDraftMediaUris)) {
for (String uriString : loadedDraftMediaUris) {
Uri uri = Uri.parse(uriString);
long mediaSize = MediaUtils.getMediaSize(getContentResolver(), uri);
long mediaSize = getMediaSize(getContentResolver(), uri);
pickMedia(uri, mediaSize);
}
} else if (savedMediaQueued != null) {
for (SavedQueuedMedia item : savedMediaQueued) {
Bitmap preview = MediaUtils.getImageThumbnail(getContentResolver(), item.uri, thumbnailViewSize);
Bitmap preview = getImageThumbnail(getContentResolver(), item.uri, thumbnailViewSize);
addMediaToQueue(item.id, item.type, preview, item.uri, item.mediaSize, item.readyStage, item.description);
}
} else if (intent != null && savedInstanceState == null) {
@ -588,7 +593,7 @@ public final class ComposeActivity
}
}
for (Uri uri : uriList) {
long mediaSize = MediaUtils.getMediaSize(getContentResolver(), uri);
long mediaSize = getMediaSize(getContentResolver(), uri);
pickMedia(uri, mediaSize);
}
} else if (type.equals("text/plain")) {
@ -874,7 +879,7 @@ public final class ComposeActivity
// Just eat this exception.
}
} else {
mediaSize = MediaUtils.MEDIA_SIZE_UNKNOWN;
mediaSize = MEDIA_SIZE_UNKNOWN;
}
pickMedia(uri, mediaSize);
@ -1089,7 +1094,7 @@ public final class ComposeActivity
try {
if (type == QueuedMedia.Type.IMAGE &&
(mediaSize > STATUS_IMAGE_SIZE_LIMIT || MediaUtils.getImageSquarePixels(getContentResolver(), item.uri) > STATUS_IMAGE_PIXEL_SIZE_LIMIT)) {
(mediaSize > STATUS_IMAGE_SIZE_LIMIT || getImageSquarePixels(getContentResolver(), item.uri) > STATUS_IMAGE_PIXEL_SIZE_LIMIT)) {
downsizeMedia(item);
} else {
uploadMedia(item);
@ -1132,7 +1137,7 @@ public final class ComposeActivity
getWindowManager().getDefaultDisplay().getMetrics(displayMetrics);
Single.fromCallable(() ->
MediaUtils.getSampledBitmap(getContentResolver(), item.uri, displayMetrics.widthPixels, displayMetrics.heightPixels))
getSampledBitmap(getContentResolver(), item.uri, displayMetrics.widthPixels, displayMetrics.heightPixels))
.subscribeOn(Schedulers.computation())
.observeOn(AndroidSchedulers.mainThread())
.as(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY)))
@ -1275,7 +1280,7 @@ public final class ComposeActivity
item.preview.setProgress(0);
ProgressRequestBody fileBody = new ProgressRequestBody(stream, MediaUtils.getMediaSize(getContentResolver(), item.uri), MediaType.parse(mimeType),
ProgressRequestBody fileBody = new ProgressRequestBody(stream, getMediaSize(getContentResolver(), item.uri), MediaType.parse(mimeType),
new ProgressRequestBody.UploadCallback() { // may reference activity longer than I would like to
int lastProgress = -1;
@ -1350,17 +1355,17 @@ public final class ComposeActivity
super.onActivityResult(requestCode, resultCode, intent);
if (resultCode == RESULT_OK && requestCode == MEDIA_PICK_RESULT && intent != null) {
Uri uri = intent.getData();
long mediaSize = MediaUtils.getMediaSize(getContentResolver(), uri);
long mediaSize = getMediaSize(getContentResolver(), uri);
pickMedia(uri, mediaSize);
} else if (resultCode == RESULT_OK && requestCode == MEDIA_TAKE_PHOTO_RESULT) {
long mediaSize = MediaUtils.getMediaSize(getContentResolver(), photoUploadUri);
long mediaSize = getMediaSize(getContentResolver(), photoUploadUri);
pickMedia(photoUploadUri, mediaSize);
}
}
private void pickMedia(Uri uri, long mediaSize) {
if (mediaSize == MediaUtils.MEDIA_SIZE_UNKNOWN) {
if (mediaSize == MEDIA_SIZE_UNKNOWN) {
displayTransientError(R.string.error_media_upload_opening);
return;
}
@ -1379,7 +1384,7 @@ public final class ComposeActivity
displayTransientError(R.string.error_media_upload_image_or_video);
return;
}
Bitmap bitmap = MediaUtils.getVideoThumbnail(this, uri, thumbnailViewSize);
Bitmap bitmap = getVideoThumbnail(this, uri, thumbnailViewSize);
if (bitmap != null) {
addMediaToQueue(QueuedMedia.Type.VIDEO, bitmap, uri, mediaSize);
} else {
@ -1388,7 +1393,7 @@ public final class ComposeActivity
break;
}
case "image": {
Bitmap bitmap = MediaUtils.getImageThumbnail(contentResolver, uri, thumbnailViewSize);
Bitmap bitmap = getImageThumbnail(contentResolver, uri, thumbnailViewSize);
if (bitmap != null) {
addMediaToQueue(QueuedMedia.Type.IMAGE, bitmap, uri, mediaSize);
} else {

View file

@ -74,6 +74,7 @@ import retrofit2.Call;
import retrofit2.Callback;
import retrofit2.Response;
import static com.keylesspalace.tusky.util.MediaUtilsKt.deleteStaleCachedMedia;
import static com.uber.autodispose.AutoDispose.autoDisposable;
import static com.uber.autodispose.android.lifecycle.AndroidLifecycleScopeProvider.from;
@ -228,6 +229,8 @@ public final class MainActivity extends BottomSheetActivity implements ActionBut
}
});
// Flush old media that was cached for sharing
deleteStaleCachedMedia(getApplicationContext().getExternalFilesDir("Tusky"));
}
@Override

View file

@ -1,287 +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;
import android.Manifest;
import android.animation.Animator;
import android.animation.AnimatorListenerAdapter;
import android.app.DownloadManager;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.graphics.Color;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.os.Environment;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.support.annotation.StringRes;
import android.support.design.widget.Snackbar;
import android.support.v4.app.ActivityCompat;
import android.support.v4.content.ContextCompat;
import android.support.v4.view.PagerAdapter;
import android.support.v4.view.ViewPager;
import android.support.v7.app.ActionBar;
import android.support.v7.widget.Toolbar;
import android.view.Menu;
import android.view.View;
import android.widget.Toast;
import com.keylesspalace.tusky.entity.Attachment;
import com.keylesspalace.tusky.fragment.ViewMediaFragment;
import com.keylesspalace.tusky.pager.AvatarImagePagerAdapter;
import com.keylesspalace.tusky.pager.ImagePagerAdapter;
import com.keylesspalace.tusky.view.ImageViewPager;
import com.keylesspalace.tusky.viewdata.AttachmentViewData;
import java.io.File;
import java.util.ArrayList;
import java.util.List;
import kotlin.collections.CollectionsKt;
import kotlin.jvm.functions.Function0;
public final class ViewMediaActivity extends BaseActivity
implements ViewMediaFragment.PhotoActionsListener {
private static final String EXTRA_ATTACHMENTS = "attachments";
private static final String EXTRA_ATTACHMENT_INDEX = "index";
private static final String EXTRA_AVATAR_URL = "avatar";
public static Intent newIntent(Context context, List<AttachmentViewData> attachments, int index) {
final Intent intent = new Intent(context, ViewMediaActivity.class);
intent.putParcelableArrayListExtra(EXTRA_ATTACHMENTS, new ArrayList<>(attachments));
intent.putExtra(EXTRA_ATTACHMENT_INDEX, index);
return intent;
}
public static Intent newAvatarIntent(Context context, String url) {
final Intent intent = new Intent(context, ViewMediaActivity.class);
intent.putExtra(EXTRA_AVATAR_URL, url);
return intent;
}
private static final int PERMISSIONS_REQUEST_WRITE_EXTERNAL_STORAGE = 1;
private ImageViewPager viewPager;
private View anyView;
private Toolbar toolbar;
private List<AttachmentViewData> attachments;
private boolean isToolbarVisible = true;
private final List<ToolbarVisibilityListener> toolbarVisibilityListeners = new ArrayList<>();
public interface ToolbarVisibilityListener {
void onToolbarVisiblityChanged(boolean isVisible);
}
public Function0 addToolbarVisibilityListener(ToolbarVisibilityListener listener) {
this.toolbarVisibilityListeners.add(listener);
listener.onToolbarVisiblityChanged(isToolbarVisible);
return () -> toolbarVisibilityListeners.remove(listener);
}
public boolean isToolbarVisible() {
return isToolbarVisible;
}
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_view_media);
supportPostponeEnterTransition();
// Obtain the views.
toolbar = findViewById(R.id.toolbar);
viewPager = findViewById(R.id.view_pager);
anyView = toolbar;
// Gather the parameters.
Intent intent = getIntent();
attachments = intent.getParcelableArrayListExtra(EXTRA_ATTACHMENTS);
int initialPosition = intent.getIntExtra(EXTRA_ATTACHMENT_INDEX, 0);
final PagerAdapter adapter;
if(attachments != null) {
List<Attachment> realAttachs =
CollectionsKt.map(attachments, AttachmentViewData::getAttachment);
// Setup the view pager.
adapter = new ImagePagerAdapter(getSupportFragmentManager(),
realAttachs, initialPosition);
} else {
String avatarUrl = intent.getStringExtra(EXTRA_AVATAR_URL);
if(avatarUrl == null) {
throw new IllegalArgumentException("attachment list or avatar url has to be set");
}
adapter = new AvatarImagePagerAdapter(getSupportFragmentManager(), avatarUrl);
}
viewPager.setAdapter(adapter);
viewPager.setCurrentItem(initialPosition);
viewPager.addOnPageChangeListener(new ViewPager.OnPageChangeListener() {
@Override
public void onPageScrolled(int position, float positionOffset,
int positionOffsetPixels) {
}
@Override
public void onPageSelected(int position) {
CharSequence title = adapter.getPageTitle(position);
toolbar.setTitle(title);
}
@Override
public void onPageScrollStateChanged(int state) {
}
});
// Setup the toolbar.
setSupportActionBar(toolbar);
ActionBar actionBar = getSupportActionBar();
if (actionBar != null) {
actionBar.setDisplayHomeAsUpEnabled(true);
actionBar.setDisplayShowHomeEnabled(true);
actionBar.setTitle(adapter.getPageTitle(initialPosition));
}
toolbar.setNavigationOnClickListener(v -> supportFinishAfterTransition());
toolbar.setOnMenuItemClickListener(item -> {
int id = item.getItemId();
switch (id) {
case R.id.action_download:
downloadImage();
break;
case R.id.action_open_status:
onOpenStatus();
break;
}
return true;
});
View decorView = getWindow().getDecorView();
int uiOptions = View.SYSTEM_UI_FLAG_LOW_PROFILE;
decorView.setSystemUiVisibility(uiOptions);
getWindow().setStatusBarColor(Color.BLACK);
}
@Override
public boolean onCreateOptionsMenu(Menu menu) {
if(attachments != null) {
getMenuInflater().inflate(R.menu.view_media_toolbar, menu);
return true;
} else {
return false;
}
}
@Override
public void onBringUp() {
supportStartPostponedEnterTransition();
}
@Override
public void onDismiss() {
supportFinishAfterTransition();
}
@Override
public void onPhotoTap() {
isToolbarVisible = !isToolbarVisible;
for (ToolbarVisibilityListener listener : toolbarVisibilityListeners) {
listener.onToolbarVisiblityChanged(isToolbarVisible);
}
final int visibility = isToolbarVisible ? View.VISIBLE : View.INVISIBLE;
int alpha = isToolbarVisible ? 1 : 0;
toolbar.animate().alpha(alpha)
.setListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(Animator animation) {
toolbar.setVisibility(visibility);
animation.removeListener(this);
}
})
.start();
}
@Override
public void onRequestPermissionsResult(int requestCode, @NonNull String permissions[],
@NonNull int[] grantResults) {
switch (requestCode) {
case PERMISSIONS_REQUEST_WRITE_EXTERNAL_STORAGE: {
if (grantResults.length > 0
&& grantResults[0] == PackageManager.PERMISSION_GRANTED) {
downloadImage();
} else {
doErrorDialog(R.string.error_media_download_permission, R.string.action_retry,
v -> downloadImage());
}
break;
}
}
}
private void doErrorDialog(@StringRes int descriptionId, @StringRes int actionId,
View.OnClickListener listener) {
if (anyView != null) {
Snackbar bar = Snackbar.make(anyView, getString(descriptionId),
Snackbar.LENGTH_SHORT);
bar.setAction(actionId, listener);
bar.show();
}
}
private void downloadImage() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M &&
ContextCompat.checkSelfPermission(this, Manifest.permission.WRITE_EXTERNAL_STORAGE)
!= PackageManager.PERMISSION_GRANTED) {
ActivityCompat.requestPermissions(this,
new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE},
PERMISSIONS_REQUEST_WRITE_EXTERNAL_STORAGE);
} else {
String url = attachments.get(viewPager.getCurrentItem()).getAttachment().getUrl();
Uri uri = Uri.parse(url);
String filename = new File(url).getName();
String toastText = String.format(getResources().getString(R.string.download_image),
filename);
Toast.makeText(this.getApplicationContext(), toastText, Toast.LENGTH_SHORT).show();
DownloadManager downloadManager =
(DownloadManager) getSystemService(Context.DOWNLOAD_SERVICE);
DownloadManager.Request request = new DownloadManager.Request(uri);
request.allowScanningByMediaScanner();
request.setDestinationInExternalPublicDir(Environment.DIRECTORY_PICTURES,
getString(R.string.app_name) + "/" + filename);
downloadManager.enqueue(request);
}
}
private void onOpenStatus() {
final AttachmentViewData attach = attachments.get(viewPager.getCurrentItem());
startActivityWithSlideInAnimation(ViewThreadActivity.startIntent(this, attach.getStatusId(),
attach.getStatusUrl()));
}
}

View file

@ -0,0 +1,239 @@
/* 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
import android.animation.Animator
import android.animation.AnimatorListenerAdapter
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.Bitmap
import android.graphics.Color
import android.graphics.drawable.Drawable
import android.net.Uri
import android.os.Bundle
import android.support.v4.content.FileProvider
import android.support.v4.view.ViewPager
import android.util.Log
import android.view.Menu
import android.view.MenuItem
import android.view.View
import com.keylesspalace.tusky.BuildConfig.APPLICATION_ID
import com.keylesspalace.tusky.fragment.ViewMediaFragment
import com.keylesspalace.tusky.pager.AvatarImagePagerAdapter
import com.keylesspalace.tusky.pager.ImagePagerAdapter
import com.keylesspalace.tusky.util.CollectionUtil.map
import com.keylesspalace.tusky.util.getTemporaryMediaFilename
import com.keylesspalace.tusky.viewdata.AttachmentViewData
import com.squareup.picasso.Picasso
import com.squareup.picasso.Target
import kotlinx.android.synthetic.main.activity_view_media.*
import java.io.File
import java.io.FileNotFoundException
import java.io.FileOutputStream
import java.io.IOException
import java.util.ArrayList
class ViewMediaActivity : BaseActivity(), ViewMediaFragment.PhotoActionsListener {
companion object {
private const val EXTRA_ATTACHMENTS = "attachments"
private const val EXTRA_ATTACHMENT_INDEX = "index"
private const val EXTRA_AVATAR_URL = "avatar"
private const val TAG = "ViewMediaActivity"
@JvmStatic
fun newIntent(context: Context?, attachments: List<AttachmentViewData>, index: Int): Intent {
val intent = Intent(context, ViewMediaActivity::class.java)
intent.putParcelableArrayListExtra(EXTRA_ATTACHMENTS, ArrayList(attachments))
intent.putExtra(EXTRA_ATTACHMENT_INDEX, index)
return intent
}
fun newAvatarIntent(context: Context, url: String): Intent {
val intent = Intent(context, ViewMediaActivity::class.java)
intent.putExtra(EXTRA_AVATAR_URL, url)
return intent
}
}
private var attachments: ArrayList<AttachmentViewData>? = null
private var toolbarVisible = true
private val toolbarVisibilityListeners = ArrayList<ToolbarVisibilityListener>()
interface ToolbarVisibilityListener {
fun onToolbarVisiblityChanged(isVisible: Boolean)
}
fun addToolbarVisibilityListener(listener: ToolbarVisibilityListener): Function0<Boolean> {
this.toolbarVisibilityListeners.add(listener)
listener.onToolbarVisiblityChanged(toolbarVisible)
return { toolbarVisibilityListeners.remove(listener) }
}
fun isToolbarVisible(): Boolean {
return toolbarVisible
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_view_media)
supportPostponeEnterTransition()
// Gather the parameters.
attachments = intent.getParcelableArrayListExtra(EXTRA_ATTACHMENTS)
val initialPosition = intent.getIntExtra(EXTRA_ATTACHMENT_INDEX, 0)
val adapter = if(attachments != null) {
val realAttachs = map(attachments, AttachmentViewData::attachment)
// Setup the view pager.
ImagePagerAdapter(supportFragmentManager, realAttachs, initialPosition)
} else {
val avatarUrl = intent.getStringExtra(EXTRA_AVATAR_URL) ?: throw IllegalArgumentException("attachment list or avatar url has to be set")
AvatarImagePagerAdapter(supportFragmentManager, avatarUrl)
}
viewPager.adapter = adapter
viewPager.currentItem = initialPosition
viewPager.addOnPageChangeListener(object: ViewPager.OnPageChangeListener {
override fun onPageSelected(position: Int) {
toolbar.title = adapter.getPageTitle(position)
}
override fun onPageScrolled(position: Int, positionOffset: Float, positionOffsetPixels: Int) {}
override fun onPageScrollStateChanged(state: Int) {}
})
// Setup the toolbar.
setSupportActionBar(toolbar)
val actionBar = supportActionBar
if (actionBar != null) {
actionBar.setDisplayHomeAsUpEnabled(true)
actionBar.setDisplayShowHomeEnabled(true)
actionBar.title = adapter.getPageTitle(initialPosition)
}
toolbar.setNavigationOnClickListener { _ -> supportFinishAfterTransition() }
toolbar.setOnMenuItemClickListener { item: MenuItem ->
when (item.itemId) {
R.id.action_download -> downloadImage()
R.id.action_open_status -> onOpenStatus()
R.id.action_share_media -> shareImage()
}
true
}
window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_LOW_PROFILE
window.statusBarColor = Color.BLACK
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
if(attachments != null) {
menuInflater.inflate(R.menu.view_media_toolbar, menu)
return true
}
return false
}
override fun onBringUp() {
supportStartPostponedEnterTransition()
}
override fun onDismiss() {
supportFinishAfterTransition()
}
override fun onPhotoTap() {
toolbarVisible = !toolbarVisible
for (listener in toolbarVisibilityListeners) {
listener.onToolbarVisiblityChanged(toolbarVisible)
}
val visibility = if(toolbarVisible){ View.VISIBLE } else { View.INVISIBLE }
val alpha = if(toolbarVisible){ 1.0f } else { 0.0f }
toolbar.animate().alpha(alpha)
.setListener(object: AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator) {
toolbar.visibility = visibility
animation.removeListener(this)
}
})
.start()
}
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
when (requestCode) {
PERMISSIONS_REQUEST_WRITE_EXTERNAL_STORAGE -> {
if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
downloadImage()
} else {
showErrorDialog(toolbar, R.string.error_media_download_permission, R.string.action_retry) { _ -> downloadImage() }
}
}
}
}
private fun downloadImage() {
downloadFile(attachments!![viewPager.currentItem].attachment.url)
}
private fun onOpenStatus() {
val attach = attachments!![viewPager.currentItem]
startActivityWithSlideInAnimation(ViewThreadActivity.startIntent(this, attach.statusId, attach.statusUrl))
}
private fun shareImage() {
val directory = applicationContext.getExternalFilesDir("Tusky")
if (directory == null || !(directory.exists())) {
Log.e(TAG, "Error obtaining directory to save temporary media.")
return
}
val attachment = attachments!![viewPager.currentItem].attachment
val context = applicationContext
val file = File(directory, getTemporaryMediaFilename("png"))
Picasso.with(context).load(Uri.parse(attachment.url)).into(object: Target {
override fun onBitmapLoaded(bitmap: Bitmap, from: Picasso.LoadedFrom) {
try {
val stream = FileOutputStream(file)
bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream)
stream.close()
} catch (fnfe: FileNotFoundException) {
Log.e(TAG, "Error writing temporary media.")
} catch (ioe: IOException) {
Log.e(TAG, "Error writing temporary media.")
}
}
override fun onBitmapFailed(errorDrawable: Drawable) {
Log.e(TAG, "Error loading temporary media.")
}
override fun onPrepareLoad(placeHolderDrawable: Drawable) { }
})
val sendIntent = Intent()
sendIntent.action = Intent.ACTION_SEND
sendIntent.putExtra(Intent.EXTRA_STREAM, FileProvider.getUriForFile(context, "$APPLICATION_ID.fileprovider", file))
sendIntent.type = "image/png"
startActivity(Intent.createChooser(sendIntent, resources.getText(R.string.send_media_to)))
}
}

View file

@ -1,119 +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;
import android.animation.Animator;
import android.animation.AnimatorListenerAdapter;
import android.graphics.Color;
import android.media.MediaPlayer;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.support.v7.app.ActionBar;
import android.support.v7.widget.Toolbar;
import android.view.MenuItem;
import android.view.MotionEvent;
import android.view.View;
import android.widget.MediaController;
import android.widget.ProgressBar;
import android.widget.VideoView;
public class ViewVideoActivity extends BaseActivity {
Handler handler = new Handler(Looper.getMainLooper());
Toolbar toolbar;
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_view_video);
final ProgressBar progressBar = findViewById(R.id.video_progress);
VideoView videoView = findViewById(R.id.video_player);
toolbar = findViewById(R.id.toolbar);
setSupportActionBar(toolbar);
ActionBar bar = getSupportActionBar();
if (bar != null) {
bar.setTitle(null);
bar.setDisplayHomeAsUpEnabled(true);
bar.setDisplayShowHomeEnabled(true);
}
String url = getIntent().getStringExtra("url");
videoView.setVideoPath(url);
MediaController controller = new MediaController(this);
controller.setMediaPlayer(videoView);
videoView.setMediaController(controller);
videoView.requestFocus();
videoView.setOnPreparedListener(new MediaPlayer.OnPreparedListener() {
@Override
public void onPrepared(MediaPlayer mp) {
progressBar.setVisibility(View.GONE);
mp.setLooping(true);
hideToolbarAfterDelay();
}
});
videoView.start();
videoView.setOnTouchListener(new View.OnTouchListener() {
@Override
public boolean onTouch(View v, MotionEvent event) {
if (event.getAction() == MotionEvent.ACTION_DOWN) {
handler.removeCallbacksAndMessages(null);
toolbar.animate().cancel();
toolbar.setAlpha(1);
toolbar.setVisibility(View.VISIBLE);
hideToolbarAfterDelay();
}
return false;
}
});
getWindow().setStatusBarColor(Color.BLACK);
}
@Override
public boolean onOptionsItemSelected(MenuItem item) {
switch (item.getItemId()) {
case android.R.id.home: {
onBackPressed();
return true;
}
}
return super.onOptionsItemSelected(item);
}
void hideToolbarAfterDelay() {
handler.postDelayed(new Runnable() {
@Override
public void run() {
toolbar.animate().alpha(0).setListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(Animator animation) {
View decorView = getWindow().getDecorView();
int uiOptions = View.SYSTEM_UI_FLAG_LOW_PROFILE;
decorView.setSystemUiVisibility(uiOptions);
toolbar.setVisibility(View.INVISIBLE);
animation.removeListener(this);
}
});
}
}, 3000);
}
}

View file

@ -0,0 +1,185 @@
/* 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
import android.animation.Animator
import android.animation.AnimatorListenerAdapter
import android.annotation.SuppressLint
import android.app.DownloadManager
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.Color
import android.net.Uri
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.support.v4.content.FileProvider
import android.util.Log
import android.view.Menu
import android.view.MenuItem
import android.view.MotionEvent
import android.view.View
import android.webkit.MimeTypeMap
import android.widget.MediaController
import kotlinx.android.synthetic.main.activity_view_video.*
import java.io.File
import com.keylesspalace.tusky.BuildConfig.APPLICATION_ID
import com.keylesspalace.tusky.util.getTemporaryMediaFilename
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.show
class ViewVideoActivity: BaseActivity() {
private val handler = Handler(Looper.getMainLooper())
private lateinit var url: String
private lateinit var statusID: String
private lateinit var statusURL: String
companion object {
private const val TAG = "ViewVideoActivity"
const val URL_EXTRA = "url"
const val STATUS_ID_EXTRA = "statusID"
const val STATUS_URL_EXTRA = "statusURL"
}
@SuppressLint("ClickableViewAccessibility")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_view_video)
setSupportActionBar(toolbar)
val bar = supportActionBar
if (bar != null) {
bar.title = null
bar.setDisplayHomeAsUpEnabled(true)
bar.setDisplayShowHomeEnabled(true)
}
toolbar.setOnMenuItemClickListener {item ->
val id = item.itemId
when (id) {
R.id.action_download -> downloadFile(url)
R.id.action_open_status -> onOpenStatus()
R.id.action_share_media -> shareVideo()
}
true
}
url = intent.getStringExtra(URL_EXTRA)
statusID = intent.getStringExtra(STATUS_ID_EXTRA)
statusURL = intent.getStringExtra(STATUS_URL_EXTRA)
videoPlayer.setVideoPath(url)
val controller = MediaController(this)
controller.setMediaPlayer(videoPlayer)
videoPlayer.setMediaController(controller)
videoPlayer.requestFocus()
videoPlayer.setOnPreparedListener { mp ->
videoProgressBar.hide()
mp.isLooping = true
hideToolbarAfterDelay()
}
videoPlayer.start()
videoPlayer.setOnTouchListener { _, event ->
if (event.action == MotionEvent.ACTION_DOWN) {
handler.removeCallbacksAndMessages(null)
toolbar.animate().cancel()
toolbar.alpha = 1.0f
toolbar.show()
hideToolbarAfterDelay()
}
false
}
window.statusBarColor = Color.BLACK
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
android.R.id.home -> {
onBackPressed()
return true
}
}
return super.onOptionsItemSelected(item)
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.view_media_toolbar, menu)
return true
}
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
when (requestCode) {
PERMISSIONS_REQUEST_WRITE_EXTERNAL_STORAGE -> {
if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
downloadFile(url)
} else {
showErrorDialog(toolbar, R.string.error_media_download_permission, R.string.action_retry) { _ -> downloadFile(url) }
}
}
}
}
private fun hideToolbarAfterDelay() {
handler.postDelayed({
toolbar.animate().alpha(0.0f).setListener(object: AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator) {
val decorView = window.decorView
val uiOptions = View.SYSTEM_UI_FLAG_LOW_PROFILE
decorView.systemUiVisibility = uiOptions
toolbar.hide()
animation.removeListener(this)
}
})
}, 3000)
}
private fun onOpenStatus() {
startActivityWithSlideInAnimation(ViewThreadActivity.startIntent(this, statusID, statusURL))
}
private fun shareVideo() {
val directory = applicationContext.getExternalFilesDir("Tusky")
if (directory == null || !(directory.exists())) {
Log.e(TAG, "Error obtaining directory to save temporary media.")
return
}
val uri = Uri.parse(url)
val mimeTypeMap = MimeTypeMap.getSingleton()
val extension = MimeTypeMap.getFileExtensionFromUrl(url)
val mimeType = mimeTypeMap.getMimeTypeFromExtension(extension)
val filename = getTemporaryMediaFilename(extension)
val file = File(directory, filename)
val downloadManager = getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
val request = DownloadManager.Request(uri)
request.setDestinationUri(Uri.fromFile(file))
request.setVisibleInDownloadsUi(false)
downloadManager.enqueue(request)
val sendIntent = Intent()
sendIntent.action = Intent.ACTION_SEND
sendIntent.putExtra(Intent.EXTRA_STREAM, FileProvider.getUriForFile(applicationContext, "$APPLICATION_ID.fileprovider", file))
sendIntent.type = mimeType
startActivity(Intent.createChooser(sendIntent, resources.getText(R.string.send_media_to)))
}
}

View file

@ -220,7 +220,9 @@ class AccountMediaFragment : BaseFragment(), Injectable {
}
Attachment.Type.GIFV, Attachment.Type.VIDEO -> {
val intent = Intent(context, ViewVideoActivity::class.java)
intent.putExtra("url", items[currentIndex].attachment.url)
intent.putExtra(ViewVideoActivity.URL_EXTRA, items[currentIndex].attachment.url)
intent.putExtra(ViewVideoActivity.STATUS_ID_EXTRA, items[currentIndex].statusId)
intent.putExtra(ViewVideoActivity.STATUS_URL_EXTRA, items[currentIndex].statusUrl)
startActivity(intent)
}
Attachment.Type.UNKNOWN -> {

View file

@ -243,7 +243,9 @@ public abstract class SFragment extends BaseFragment {
case GIFV:
case VIDEO: {
Intent intent = new Intent(getContext(), ViewVideoActivity.class);
intent.putExtra("url", active.getUrl());
intent.putExtra(ViewVideoActivity.URL_EXTRA, active.getUrl());
intent.putExtra(ViewVideoActivity.STATUS_ID_EXTRA, actionable.getId());
intent.putExtra(ViewVideoActivity.STATUS_URL_EXTRA, actionable.getUrl());
startActivity(intent);
break;
}

View file

@ -27,6 +27,10 @@ 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.
@ -65,7 +69,7 @@ public class DownsizeImageTask extends AsyncTask<Uri, Void, Boolean> {
BitmapFactory.decodeStream(inputStream, null, options);
IOUtils.closeQuietly(inputStream);
// Get EXIF data, for orientation info.
int orientation = MediaUtils.getImageOrientation(uri, contentResolver);
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
@ -84,7 +88,7 @@ public class DownsizeImageTask extends AsyncTask<Uri, Void, Boolean> {
} catch (FileNotFoundException e) {
return false;
}
options.inSampleSize = MediaUtils.calculateInSampleSize(options, scaledImageSize, scaledImageSize);
options.inSampleSize = calculateInSampleSize(options, scaledImageSize, scaledImageSize);
options.inJustDecodeBounds = false;
Bitmap scaledBitmap;
try {
@ -97,7 +101,7 @@ public class DownsizeImageTask extends AsyncTask<Uri, Void, Boolean> {
if (scaledBitmap == null) {
return false;
}
Bitmap reorientedBitmap = MediaUtils.reorientBitmap(scaledBitmap, orientation);
Bitmap reorientedBitmap = reorientBitmap(scaledBitmap, orientation);
if (reorientedBitmap == null) {
scaledBitmap.recycle();
return false;

View file

@ -1,260 +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.content.Context;
import android.database.Cursor;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.Matrix;
import android.media.MediaMetadataRetriever;
import android.media.ThumbnailUtils;
import android.net.Uri;
import android.provider.OpenableColumns;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.support.annotation.Px;
import android.support.media.ExifInterface;
import android.util.Log;
import java.io.ByteArrayOutputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
/**
* Class with helper methods for obtaining and resizing media files
*/
public class MediaUtils {
private static final String TAG = "MediaUtils";
public static final int MEDIA_SIZE_UNKNOWN = -1;
/**
* Copies the entire contents of the given stream into a byte array and returns it. Beware of
* OutOfMemoryError for streams of unknown size.
*/
@Nullable
public static byte[] inputStreamGetBytes(InputStream stream) {
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
int read;
byte[] data = new byte[16384];
try {
while ((read = stream.read(data, 0, data.length)) != -1) {
buffer.write(data, 0, read);
}
buffer.flush();
} catch (IOException e) {
return null;
}
return buffer.toByteArray();
}
/**
* Fetches the size of the media represented by the given URI, assuming it is openable and
* the ContentResolver is able to resolve it.
*
* @return the size of the media in bytes or {@link MediaUtils#MEDIA_SIZE_UNKNOWN}
*/
public static long getMediaSize(@NonNull ContentResolver contentResolver, @Nullable Uri uri) {
if(uri == null) return MEDIA_SIZE_UNKNOWN;
long mediaSize;
Cursor cursor;
try {
cursor = contentResolver.query(uri, null, null, null, null);
} catch (SecurityException e) {
return MEDIA_SIZE_UNKNOWN;
}
if (cursor != null) {
int sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE);
cursor.moveToFirst();
mediaSize = cursor.getLong(sizeIndex);
cursor.close();
} else {
mediaSize = MEDIA_SIZE_UNKNOWN;
}
return mediaSize;
}
@Nullable
public static Bitmap getSampledBitmap(ContentResolver contentResolver, Uri uri, @Px int reqWidth, @Px int reqHeight) {
// First decode with inJustDecodeBounds=true to check dimensions
final BitmapFactory.Options options = new BitmapFactory.Options();
options.inJustDecodeBounds = true;
InputStream stream;
try {
stream = contentResolver.openInputStream(uri);
} catch (FileNotFoundException e) {
Log.w(TAG, e);
return null;
}
BitmapFactory.decodeStream(stream, null, options);
IOUtils.closeQuietly(stream);
// Calculate inSampleSize
options.inSampleSize = calculateInSampleSize(options, reqWidth, reqHeight);
// Decode bitmap with inSampleSize set
options.inJustDecodeBounds = false;
try {
stream = contentResolver.openInputStream(uri);
Bitmap bitmap = BitmapFactory.decodeStream(stream, null, options);
int orientation = getImageOrientation(uri, contentResolver);
return reorientBitmap(bitmap, orientation);
} catch (FileNotFoundException e) {
Log.w(TAG, e);
return null;
} catch (OutOfMemoryError e) {
Log.e(TAG, "OutOfMemoryError while trying to get sampled Bitmap", e);
return null;
} finally {
IOUtils.closeQuietly(stream);
}
}
@Nullable
public static Bitmap getImageThumbnail(ContentResolver contentResolver, Uri uri, @Px int thumbnailSize) {
Bitmap source = getSampledBitmap(contentResolver, uri, thumbnailSize, thumbnailSize);
if(source == null) {
return null;
}
return ThumbnailUtils.extractThumbnail(source, thumbnailSize, thumbnailSize, ThumbnailUtils.OPTIONS_RECYCLE_INPUT);
}
@Nullable
public static Bitmap getVideoThumbnail(Context context, Uri uri, @Px int thumbnailSize) {
MediaMetadataRetriever retriever = new MediaMetadataRetriever();
retriever.setDataSource(context, uri);
Bitmap source = retriever.getFrameAtTime();
if (source == null) {
return null;
}
return ThumbnailUtils.extractThumbnail(source, thumbnailSize, thumbnailSize, ThumbnailUtils.OPTIONS_RECYCLE_INPUT);
}
public static long getImageSquarePixels(ContentResolver contentResolver, Uri uri) throws FileNotFoundException {
InputStream input = contentResolver.openInputStream(uri);
final BitmapFactory.Options options = new BitmapFactory.Options();
options.inJustDecodeBounds = true;
BitmapFactory.decodeStream(input, null, options);
IOUtils.closeQuietly(input);
return (long) options.outWidth * options.outHeight;
}
public static int calculateInSampleSize(BitmapFactory.Options options, int reqWidth, int reqHeight) {
// Raw height and width of image
final int height = options.outHeight;
final int width = options.outWidth;
int inSampleSize = 1;
if (height > reqHeight || width > reqWidth) {
final int halfHeight = height / 2;
final int halfWidth = width / 2;
// Calculate the largest inSampleSize value that is a power of 2 and keeps both
// height and width larger than the requested height and width.
while ((halfHeight / inSampleSize) > reqHeight && (halfWidth / inSampleSize) > reqWidth) {
inSampleSize *= 2;
}
}
return inSampleSize;
}
@Nullable
public static Bitmap reorientBitmap(Bitmap bitmap, int orientation) {
Matrix matrix = new Matrix();
switch (orientation) {
default:
case ExifInterface.ORIENTATION_NORMAL: {
return bitmap;
}
case ExifInterface.ORIENTATION_FLIP_HORIZONTAL: {
matrix.setScale(-1, 1);
break;
}
case ExifInterface.ORIENTATION_ROTATE_180: {
matrix.setRotate(180);
break;
}
case ExifInterface.ORIENTATION_FLIP_VERTICAL: {
matrix.setRotate(180);
matrix.postScale(-1, 1);
break;
}
case ExifInterface.ORIENTATION_TRANSPOSE: {
matrix.setRotate(90);
matrix.postScale(-1, 1);
break;
}
case ExifInterface.ORIENTATION_ROTATE_90: {
matrix.setRotate(90);
break;
}
case ExifInterface.ORIENTATION_TRANSVERSE: {
matrix.setRotate(-90);
matrix.postScale(-1, 1);
break;
}
case ExifInterface.ORIENTATION_ROTATE_270: {
matrix.setRotate(-90);
break;
}
}
try {
Bitmap result = Bitmap.createBitmap(bitmap, 0, 0, bitmap.getWidth(),
bitmap.getHeight(), matrix, true);
if (!bitmap.sameAs(result)) {
bitmap.recycle();
}
return result;
} catch (OutOfMemoryError e) {
return null;
}
}
public static int getImageOrientation(Uri uri, ContentResolver contentResolver) {
InputStream inputStream;
try {
inputStream = contentResolver.openInputStream(uri);
} catch (FileNotFoundException e) {
Log.w(TAG, e);
return ExifInterface.ORIENTATION_UNDEFINED;
}
if (inputStream == null) {
return ExifInterface.ORIENTATION_UNDEFINED;
}
ExifInterface exifInterface;
try {
exifInterface = new ExifInterface(inputStream);
} catch (IOException e) {
Log.w(TAG, e);
IOUtils.closeQuietly(inputStream);
return ExifInterface.ORIENTATION_UNDEFINED;
}
int orientation = exifInterface.getAttributeInt(ExifInterface.TAG_ORIENTATION,
ExifInterface.ORIENTATION_NORMAL);
IOUtils.closeQuietly(inputStream);
return orientation;
}
}

View file

@ -0,0 +1,245 @@
/* 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.content.Context
import android.database.Cursor
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Matrix
import android.media.MediaMetadataRetriever
import android.media.ThumbnailUtils
import android.net.Uri
import android.provider.OpenableColumns
import android.support.annotation.Px
import android.support.media.ExifInterface
import android.util.Log
import java.io.*
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Date
import java.util.Locale
/**
* Helper methods for obtaining and resizing media files
*/
private const val TAG = "MediaUtils"
private const val MEDIA_TEMP_PREFIX = "Tusky_Share_Media"
const val MEDIA_SIZE_UNKNOWN = -1L
/**
* Fetches the size of the media represented by the given URI, assuming it is openable and
* the ContentResolver is able to resolve it.
*
* @return the size of the media in bytes or {@link MediaUtils#MEDIA_SIZE_UNKNOWN}
*/
fun getMediaSize(contentResolver: ContentResolver, uri: Uri?): Long {
if(uri == null) {
return MEDIA_SIZE_UNKNOWN
}
var mediaSize = MEDIA_SIZE_UNKNOWN
val cursor: Cursor?
try {
cursor = contentResolver.query(uri, null, null, null, null)
} catch (e: SecurityException) {
return MEDIA_SIZE_UNKNOWN
}
if (cursor != null) {
val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE)
cursor.moveToFirst()
mediaSize = cursor.getLong(sizeIndex)
cursor.close()
}
return mediaSize
}
fun getSampledBitmap(contentResolver: ContentResolver, uri: Uri, @Px reqWidth: Int, @Px reqHeight: Int): Bitmap? {
// First decode with inJustDecodeBounds=true to check dimensions
val options = BitmapFactory.Options()
options.inJustDecodeBounds = true
var stream: InputStream?
try {
stream = contentResolver.openInputStream(uri)
} catch (e: FileNotFoundException) {
Log.w(TAG, e)
return null
}
BitmapFactory.decodeStream(stream, null, options)
IOUtils.closeQuietly(stream)
// Calculate inSampleSize
options.inSampleSize = calculateInSampleSize(options, reqWidth, reqHeight)
// Decode bitmap with inSampleSize set
options.inJustDecodeBounds = false
return try {
stream = contentResolver.openInputStream(uri)
val bitmap = BitmapFactory.decodeStream(stream, null, options)
val orientation = getImageOrientation(uri, contentResolver)
reorientBitmap(bitmap, orientation)
} catch (e: FileNotFoundException) {
Log.w(TAG, e)
null
} catch (e: OutOfMemoryError) {
Log.e(TAG, "OutOfMemoryError while trying to get sampled Bitmap", e)
null
} finally {
IOUtils.closeQuietly(stream)
}
}
fun getImageThumbnail(contentResolver: ContentResolver, uri: Uri, @Px thumbnailSize: Int): Bitmap? {
val source = getSampledBitmap(contentResolver, uri, thumbnailSize, thumbnailSize) ?: return null
return ThumbnailUtils.extractThumbnail(source, thumbnailSize, thumbnailSize, ThumbnailUtils.OPTIONS_RECYCLE_INPUT)
}
fun getVideoThumbnail(context: Context, uri: Uri, @Px thumbnailSize: Int): Bitmap? {
val retriever = MediaMetadataRetriever()
retriever.setDataSource(context, uri)
val source = retriever.frameAtTime ?: return null
return ThumbnailUtils.extractThumbnail(source, thumbnailSize, thumbnailSize, ThumbnailUtils.OPTIONS_RECYCLE_INPUT)
}
@Throws(FileNotFoundException::class)
fun getImageSquarePixels(contentResolver: ContentResolver, uri: Uri): Long {
val input = contentResolver.openInputStream(uri)
val options = BitmapFactory.Options()
options.inJustDecodeBounds = true
BitmapFactory.decodeStream(input, null, options)
IOUtils.closeQuietly(input)
return (options.outWidth * options.outHeight).toLong()
}
fun calculateInSampleSize(options: BitmapFactory.Options, reqWidth: Int, reqHeight: Int): Int {
// Raw height and width of image
val height = options.outHeight
val width = options.outWidth
var inSampleSize = 1
if (height > reqHeight || width > reqWidth) {
val halfHeight = height / 2
val halfWidth = width / 2
// Calculate the largest inSampleSize value that is a power of 2 and keeps both
// height and width larger than the requested height and width.
while ((halfHeight / inSampleSize) > reqHeight && (halfWidth / inSampleSize) > reqWidth) {
inSampleSize *= 2
}
}
return inSampleSize
}
fun reorientBitmap(bitmap: Bitmap?, orientation: Int): Bitmap? {
val matrix = Matrix()
when (orientation) {
ExifInterface.ORIENTATION_NORMAL -> return bitmap
ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> matrix.setScale(-1.0f, 1.0f)
ExifInterface.ORIENTATION_ROTATE_180 -> matrix.setRotate(180.0f)
ExifInterface.ORIENTATION_FLIP_VERTICAL -> {
matrix.setRotate(180.0f)
matrix.postScale(-1.0f, 1.0f)
}
ExifInterface.ORIENTATION_TRANSPOSE -> {
matrix.setRotate(90.0f)
matrix.postScale(-1.0f, 1.0f)
}
ExifInterface.ORIENTATION_ROTATE_90 -> matrix.setRotate(90.0f)
ExifInterface.ORIENTATION_TRANSVERSE -> {
matrix.setRotate(-90.0f)
matrix.postScale(-1.0f, 1.0f)
}
ExifInterface.ORIENTATION_ROTATE_270 -> matrix.setRotate(-90.0f)
else -> return bitmap
}
if (bitmap == null) {
return null
}
return try {
val result = Bitmap.createBitmap(bitmap, 0, 0, bitmap.width,
bitmap.height, matrix, true)
if (!bitmap.sameAs(result)) {
bitmap.recycle()
}
result
} catch (e: OutOfMemoryError) {
null
}
}
fun getImageOrientation(uri: Uri, contentResolver: ContentResolver): Int {
val inputStream: InputStream?
try {
inputStream = contentResolver.openInputStream(uri)
} catch (e: FileNotFoundException) {
Log.w(TAG, e)
return ExifInterface.ORIENTATION_UNDEFINED
}
if (inputStream == null) {
return ExifInterface.ORIENTATION_UNDEFINED
}
val exifInterface: ExifInterface
try {
exifInterface = ExifInterface(inputStream)
} catch (e: IOException) {
Log.w(TAG, e)
IOUtils.closeQuietly(inputStream)
return ExifInterface.ORIENTATION_UNDEFINED
}
val orientation = exifInterface.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)
IOUtils.closeQuietly(inputStream)
return orientation
}
fun deleteStaleCachedMedia(mediaDirectory: File?) {
if (mediaDirectory == null || !mediaDirectory.exists()) {
// Nothing to do
return
}
val twentyfourHoursAgo = Calendar.getInstance()
twentyfourHoursAgo.add(Calendar.HOUR, -24)
val unixTime = twentyfourHoursAgo.timeInMillis
val files = mediaDirectory.listFiles{ file -> unixTime > file.lastModified() && file.name.contains(MEDIA_TEMP_PREFIX) }
if (files == null || files.isEmpty()) {
// Nothing to do
return
}
for (file in files) {
try {
file.delete()
} catch (se: SecurityException) {
Log.e(TAG, "Error removing stale cached media")
}
}
}
fun getTemporaryMediaFilename(extension: String): String {
return "${MEDIA_TEMP_PREFIX}_${SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())}.$extension"
}

View file

@ -113,7 +113,7 @@ class EditProfileViewModel @Inject constructor(
Single.fromCallable {
val contentResolver = context.contentResolver
val sourceBitmap = MediaUtils.getSampledBitmap(contentResolver, uri, resizeWidth, resizeHeight)
val sourceBitmap = getSampledBitmap(contentResolver, uri, resizeWidth, resizeHeight)
if (sourceBitmap == null) {
throw Exception()

View file

@ -0,0 +1,25 @@
<!--
Copyright (C) 2014 The Android Open Source Project
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0"
>
<path
android:pathData="M18,16.1c-0.8,0 -1.5,0.3 -2,0.8l-7.1,-4.2C9,12.5 9,12.2 9,12s0,-0.5 -0.1,-0.7L16,7.2C16.5,7.7 17.200001,8 18,8c1.7,0 3,-1.3 3,-3s-1.3,-3 -3,-3s-3,1.3 -3,3c0,0.2 0,0.5 0.1,0.7L8,9.8C7.5,9.3 6.8,9 6,9c-1.7,0 -2.9,1.2 -2.9,2.9s1.3,3 3,3c0.8,0 1.5,-0.3 2,-0.8l7.1,4.2c-0.1,0.3 -0.1,0.5 -0.1,0.7c0,1.6 1.3,2.9 2.9,2.9s2.9,-1.3 2.9,-2.9S19.6,16.1 18,16.1z"
android:fillColor="#FFF"/>
</vector>

View file

@ -9,7 +9,7 @@
<com.keylesspalace.tusky.view.ImageViewPager
android:layout_width="match_parent"
android:layout_height="match_parent"
android:id="@+id/view_pager" />
android:id="@+id/viewPager" />
<android.support.v7.widget.Toolbar
android:id="@+id/toolbar"

View file

@ -9,12 +9,12 @@
android:id="@+id/view_video_container"
tools:context=".ViewVideoActivity">
<VideoView
android:id="@+id/video_player"
android:id="@+id/videoPlayer"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_gravity="center" />
<ProgressBar
android:id="@+id/video_progress"
android:id="@+id/videoProgressBar"
android:layout_gravity="center"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
@ -23,6 +23,6 @@
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:theme="@style/AppTheme.Account.AppBarLayout"
app:popupTheme="@style/AppTheme.Account.ToolbarPopupTheme.Dark"
android:background="@color/semi_transparent" />
app:popupTheme="?attr/toolbar_popup_theme"
android:background="@color/toolbar_view_media" />
</android.support.design.widget.CoordinatorLayout>

View file

@ -6,6 +6,11 @@
android:icon="@drawable/ic_file_download_black_24dp"
android:title="@string/dialog_download_image"
app:showAsAction="ifRoom" />
<item
android:id="@+id/action_share_media"
android:icon="@drawable/ic_menu_share_24dp"
android:title="@string/action_share"
app:showAsAction="ifRoom" />
<item
android:id="@+id/action_open_status"
android:title="@string/action_open_toot"

View file

@ -114,6 +114,7 @@
<string name="send_status_link_to">Share toot URL to…</string>
<string name="send_status_content_to">Share toot to…</string>
<string name="send_media_to">Share media to…</string>
<string name="confirmation_reported">Sent!</string>
<string name="confirmation_unblocked">User unblocked</string>