From 0bca94b94eec0dc8a59826c73e078a5f6efab1c8 Mon Sep 17 00:00:00 2001 From: Levi Bard Date: Mon, 1 Oct 2018 11:50:17 +0200 Subject: [PATCH] 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 --- .../com/keylesspalace/tusky/BaseActivity.java | 45 +++ .../keylesspalace/tusky/ComposeActivity.java | 31 +- .../com/keylesspalace/tusky/MainActivity.java | 3 + .../tusky/ViewMediaActivity.java | 287 ------------------ .../keylesspalace/tusky/ViewMediaActivity.kt | 239 +++++++++++++++ .../tusky/ViewVideoActivity.java | 119 -------- .../keylesspalace/tusky/ViewVideoActivity.kt | 185 +++++++++++ .../tusky/fragment/AccountMediaFragment.kt | 4 +- .../tusky/fragment/SFragment.java | 4 +- .../tusky/util/DownsizeImageTask.java | 10 +- .../keylesspalace/tusky/util/MediaUtils.java | 260 ---------------- .../keylesspalace/tusky/util/MediaUtils.kt | 245 +++++++++++++++ .../tusky/viewmodel/EditProfileViewModel.kt | 2 +- .../main/res/drawable/ic_menu_share_24dp.xml | 25 ++ .../main/res/layout/activity_view_media.xml | 2 +- .../main/res/layout/activity_view_video.xml | 8 +- app/src/main/res/menu/view_media_toolbar.xml | 5 + app/src/main/res/values/strings.xml | 1 + 18 files changed, 785 insertions(+), 690 deletions(-) delete mode 100644 app/src/main/java/com/keylesspalace/tusky/ViewMediaActivity.java create mode 100644 app/src/main/java/com/keylesspalace/tusky/ViewMediaActivity.kt delete mode 100644 app/src/main/java/com/keylesspalace/tusky/ViewVideoActivity.java create mode 100644 app/src/main/java/com/keylesspalace/tusky/ViewVideoActivity.kt delete mode 100644 app/src/main/java/com/keylesspalace/tusky/util/MediaUtils.java create mode 100644 app/src/main/java/com/keylesspalace/tusky/util/MediaUtils.kt create mode 100644 app/src/main/res/drawable/ic_menu_share_24dp.xml diff --git a/app/src/main/java/com/keylesspalace/tusky/BaseActivity.java b/app/src/main/java/com/keylesspalace/tusky/BaseActivity.java index 5fbfe0c2..8a96fd77 100644 --- a/app/src/main/java/com/keylesspalace/tusky/BaseActivity.java +++ b/app/src/main/java/com/keylesspalace/tusky/BaseActivity.java @@ -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) { diff --git a/app/src/main/java/com/keylesspalace/tusky/ComposeActivity.java b/app/src/main/java/com/keylesspalace/tusky/ComposeActivity.java index 055a1edf..314991e8 100644 --- a/app/src/main/java/com/keylesspalace/tusky/ComposeActivity.java +++ b/app/src/main/java/com/keylesspalace/tusky/ComposeActivity.java @@ -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 { diff --git a/app/src/main/java/com/keylesspalace/tusky/MainActivity.java b/app/src/main/java/com/keylesspalace/tusky/MainActivity.java index dcbce519..730c1af3 100644 --- a/app/src/main/java/com/keylesspalace/tusky/MainActivity.java +++ b/app/src/main/java/com/keylesspalace/tusky/MainActivity.java @@ -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 diff --git a/app/src/main/java/com/keylesspalace/tusky/ViewMediaActivity.java b/app/src/main/java/com/keylesspalace/tusky/ViewMediaActivity.java deleted file mode 100644 index 11cbcedb..00000000 --- a/app/src/main/java/com/keylesspalace/tusky/ViewMediaActivity.java +++ /dev/null @@ -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 . */ - -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 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 attachments; - - private boolean isToolbarVisible = true; - private final List 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 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())); - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/ViewMediaActivity.kt b/app/src/main/java/com/keylesspalace/tusky/ViewMediaActivity.kt new file mode 100644 index 00000000..443173e1 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/ViewMediaActivity.kt @@ -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 . */ + +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, 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? = null + + private var toolbarVisible = true + private val toolbarVisibilityListeners = ArrayList() + + interface ToolbarVisibilityListener { + fun onToolbarVisiblityChanged(isVisible: Boolean) + } + + fun addToolbarVisibilityListener(listener: ToolbarVisibilityListener): Function0 { + 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, 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))) + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/ViewVideoActivity.java b/app/src/main/java/com/keylesspalace/tusky/ViewVideoActivity.java deleted file mode 100644 index 1e182ef9..00000000 --- a/app/src/main/java/com/keylesspalace/tusky/ViewVideoActivity.java +++ /dev/null @@ -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 . */ - -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); - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/ViewVideoActivity.kt b/app/src/main/java/com/keylesspalace/tusky/ViewVideoActivity.kt new file mode 100644 index 00000000..4f3d681c --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/ViewVideoActivity.kt @@ -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 . */ + +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, 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))) + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/AccountMediaFragment.kt b/app/src/main/java/com/keylesspalace/tusky/fragment/AccountMediaFragment.kt index d3459bc9..1c168ba8 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/AccountMediaFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/AccountMediaFragment.kt @@ -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 -> { diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.java b/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.java index c6ec2ceb..fdd50a00 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.java @@ -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; } diff --git a/app/src/main/java/com/keylesspalace/tusky/util/DownsizeImageTask.java b/app/src/main/java/com/keylesspalace/tusky/util/DownsizeImageTask.java index 9a48d599..364f0e84 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/DownsizeImageTask.java +++ b/app/src/main/java/com/keylesspalace/tusky/util/DownsizeImageTask.java @@ -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 { 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 { } 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 { if (scaledBitmap == null) { return false; } - Bitmap reorientedBitmap = MediaUtils.reorientBitmap(scaledBitmap, orientation); + Bitmap reorientedBitmap = reorientBitmap(scaledBitmap, orientation); if (reorientedBitmap == null) { scaledBitmap.recycle(); return false; diff --git a/app/src/main/java/com/keylesspalace/tusky/util/MediaUtils.java b/app/src/main/java/com/keylesspalace/tusky/util/MediaUtils.java deleted file mode 100644 index 0ad3adb1..00000000 --- a/app/src/main/java/com/keylesspalace/tusky/util/MediaUtils.java +++ /dev/null @@ -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 . */ - -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; - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/MediaUtils.kt b/app/src/main/java/com/keylesspalace/tusky/util/MediaUtils.kt new file mode 100644 index 00000000..10d7ca7b --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/MediaUtils.kt @@ -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 . */ + +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" +} diff --git a/app/src/main/java/com/keylesspalace/tusky/viewmodel/EditProfileViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/viewmodel/EditProfileViewModel.kt index 0987c09b..ab4d366f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/viewmodel/EditProfileViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/viewmodel/EditProfileViewModel.kt @@ -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() diff --git a/app/src/main/res/drawable/ic_menu_share_24dp.xml b/app/src/main/res/drawable/ic_menu_share_24dp.xml new file mode 100644 index 00000000..dd1be97b --- /dev/null +++ b/app/src/main/res/drawable/ic_menu_share_24dp.xml @@ -0,0 +1,25 @@ + + + + diff --git a/app/src/main/res/layout/activity_view_media.xml b/app/src/main/res/layout/activity_view_media.xml index cce61198..fd86d8d4 100644 --- a/app/src/main/res/layout/activity_view_media.xml +++ b/app/src/main/res/layout/activity_view_media.xml @@ -9,7 +9,7 @@ + android:id="@+id/viewPager" /> @@ -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" /> \ No newline at end of file diff --git a/app/src/main/res/menu/view_media_toolbar.xml b/app/src/main/res/menu/view_media_toolbar.xml index eb55440f..90d013f6 100644 --- a/app/src/main/res/menu/view_media_toolbar.xml +++ b/app/src/main/res/menu/view_media_toolbar.xml @@ -6,6 +6,11 @@ android:icon="@drawable/ic_file_download_black_24dp" android:title="@string/dialog_download_image" app:showAsAction="ifRoom" /> + Share toot URL to… Share toot to… + Share media to… Sent! User unblocked