Unverified Commit 0348696f authored by Wv5twkFEKh54vo4tta9yu7dHa3's avatar Wv5twkFEKh54vo4tta9yu7dHa3 Committed by GitHub

Improve upload flow performance & visual feedback (#224)

* Make less copies, detect if no changes are made for a fast path, give some feedback while processing the image

* avoid NPE on camera, use more generic inputstream so that file picking works again

* stop using resource in test

* stop using resource in test

* fix uri issue and add test

* Test dialog, stringify strings

* click error button, for fun

* test error button in post creation

* check retry of upload works

* Remove wrong button click in test

* add some tests for followers list

* test edit profile button

* test back button

* try to get all callbacks to be called

* Fix typo

* Make sure crop is not ignored
parent 9a758ee7
......@@ -114,13 +114,29 @@ class DrawerMenuTest {
onView(withId(R.id.profilePictureImageView)).check(matches(isDisplayed()))
}
/*@Test
fun testDrawerAvatarClick() {
@Test
fun testDrawerOwnProfileFollowers() {
// Start the screen of your activity.
onView(withText(R.string.menu_account)).perform(click())
// Check that profile activity was opened.
onView(withId(R.id.profilePictureImageView)).check(matches(isDisplayed()))
}*/
onView(withId(R.id.editButton)).check(matches(isDisplayed()))
val followersText = context.getString(R.string.nb_followers)
.format(68)
onView(withText(followersText)).perform(click())
onView(withText("Dobios")).check(matches(isDisplayed()))
}
@Test
fun testDrawerOwnProfileFollowing() {
// Start the screen of your activity.
onView(withText(R.string.menu_account)).perform(click())
// Check that profile activity was opened.
onView(withId(R.id.editButton)).check(matches(isDisplayed()))
val followingText = context.getString(R.string.nb_following)
.format(27)
onView(withText(followingText)).perform(click())
onView(withText("Dobios")).check(matches(isDisplayed()))
}
/*@Test
fun testDrawerAccountNameClick() {
......
package com.h.pixeldroid
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.Color
import android.media.MediaScannerConnection
import android.net.Uri
import android.os.Environment
import android.view.View
import android.webkit.MimeTypeMap
import android.widget.SeekBar
import androidx.core.net.toUri
import androidx.lifecycle.Lifecycle
import androidx.test.core.app.ActivityScenario
import androidx.test.espresso.Espresso
import androidx.test.espresso.PerformException
......@@ -19,7 +26,9 @@ import androidx.test.rule.GrantPermissionRule
import com.google.android.material.tabs.TabLayout
import com.h.pixeldroid.adapters.ThumbnailAdapter
import com.h.pixeldroid.testUtility.CustomMatchers
import junit.framework.Assert.assertTrue
import kotlinx.android.synthetic.main.fragment_edit_image.*
import org.hamcrest.CoreMatchers
import org.hamcrest.CoreMatchers.allOf
import org.junit.Assert
import org.junit.Before
......@@ -27,6 +36,8 @@ import org.junit.Rule
import org.junit.Test
import org.junit.rules.Timeout
import org.junit.runner.RunWith
import java.io.File
import java.net.URI
@RunWith(AndroidJUnit4::class)
class EditPhotoTest {
......@@ -40,12 +51,30 @@ class EditPhotoTest {
@get:Rule
var mRuntimePermissionRule = GrantPermissionRule.grant(android.Manifest.permission.WRITE_EXTERNAL_STORAGE)
private fun File.writeBitmap(bitmap: Bitmap) {
outputStream().use { out ->
bitmap.compress(Bitmap.CompressFormat.PNG, 85, out)
out.flush()
}
}
@Before
fun before() {
val context = InstrumentationRegistry.getInstrumentation().targetContext
// Launch PhotoEditActivity
val uri: Uri = Uri.parse("android.resource://com.h.pixeldroid/drawable/index")
var uri: Uri = "".toUri()
val scenario = ActivityScenario.launch(ProfileActivity::class.java)
scenario.onActivity {
val image = Bitmap.createBitmap(500, 500, Bitmap.Config.ARGB_8888)
image.eraseColor(Color.GREEN)
val folder =
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DCIM)
if (!folder.exists()) {
folder.mkdir()
}
val file = File.createTempFile("temp_img", ".png", folder)
file.writeBitmap(image)
uri = file.toUri()
}
val intent = Intent(context, PhotoEditActivity::class.java).putExtra("picture_uri", uri)
activityScenario = ActivityScenario.launch<PhotoEditActivity>(intent).onActivity{a -> activity = a}
......@@ -138,6 +167,12 @@ class EditPhotoTest {
.check(matches(withText(R.string.save_image_success)))
}
@Test
fun backButton() {
Espresso.onView(withId(R.id.toolbar)).check(matches(isDisplayed()))
Espresso.onView(withContentDescription(R.string.abc_action_bar_up_description)).perform(click());
assertTrue(activityScenario.state == Lifecycle.State.DESTROYED) }
@Test
fun buttonUploadLaunchNewPostActivity() {
Espresso.onView(withId(R.id.action_upload)).perform(click())
......@@ -145,6 +180,23 @@ class EditPhotoTest {
Espresso.onView(withId(R.id.post_creation_picture_frame)).check(matches(isDisplayed()))
}
@Test
fun modifiedUploadLaunchesNewPostActivity() {
Espresso.onView(withId(R.id.recycler_view))
.perform(actionOnItemAtPosition<ThumbnailAdapter.MyViewHolder>(2, CustomMatchers.clickChildViewWithId(R.id.thumbnail)))
Thread.sleep(1000)
Espresso.onView(withId(R.id.tabs)).perform(selectTabAtPosition(1))
Espresso.onView(withId(R.id.seekbar_brightness)).perform(setProgress(5))
Thread.sleep(1000)
Espresso.onView(withId(R.id.action_upload)).perform(click())
Thread.sleep(1000)
Espresso.onView(withId(R.id.post_creation_picture_frame)).check(matches(isDisplayed()))
}
@Test
fun croppingIsPossible() {
Espresso.onView(withId(R.id.cropImageButton)).perform(click())
......@@ -152,4 +204,12 @@ class EditPhotoTest {
Espresso.onView(withId(R.id.menu_crop)).perform(click())
Espresso.onView(withId(R.id.image_preview)).check(matches(isDisplayed()))
}
@Test
fun alreadyUploadingDialog() {
activityScenario.onActivity { a -> a.saving = true }
Espresso.onView(withId(R.id.action_upload)).perform(click())
Thread.sleep(1000)
Espresso.onView(withText(R.string.busy_dialog_text)).check(matches(isDisplayed()))
}
}
\ No newline at end of file
......@@ -12,6 +12,10 @@ import androidx.test.espresso.Espresso
import androidx.test.espresso.NoMatchingViewException
import androidx.test.espresso.UiController
import androidx.test.espresso.ViewAction
import androidx.test.espresso.action.ViewActions
import androidx.test.espresso.assertion.ViewAssertions
import androidx.test.espresso.contrib.DrawerActions
import androidx.test.espresso.contrib.DrawerMatchers
import androidx.test.espresso.contrib.RecyclerViewActions
import androidx.test.espresso.intent.Intents
import androidx.test.espresso.intent.Intents.intended
......@@ -164,29 +168,28 @@ class IntentTest {
}
}
/*@Test
fun launchesIntent() {
// Open Drawer to click on navigation.
@Test
fun clickEditProfileMakesIntent() {
ActivityScenario.launch(MainActivity::class.java)
Espresso.onView(ViewMatchers.withId(R.id.drawer_layout))
.check(ViewAssertions.matches(DrawerMatchers.isClosed(Gravity.LEFT))) // Left Drawer should be closed.
.check(ViewAssertions.matches(DrawerMatchers.isClosed())) // Left Drawer should be closed.
.perform(DrawerActions.open()) // Open Drawer
Espresso.onView(ViewMatchers.withId(R.id.drawer))
.perform(NavigationViewActions.navigateTo(R.id.nav_account))
val expectedIntent: Matcher<Intent> = CoreMatchers.allOf(
IntentMatchers.hasAction(Intent.ACTION_VIEW),
IntentMatchers.hasDataString(CoreMatchers.containsString("settings/home"))
)
Thread.sleep(1000)
Espresso.onView(ViewMatchers.withId(R.id.editButton)).perform(ViewActions.click())
Thread.sleep(1000)
// Start the screen of your activity.
Espresso.onView(ViewMatchers.withText(R.string.menu_account)).perform(ViewActions.click())
// Check that profile activity was opened.
Espresso.onView(ViewMatchers.withId(R.id.editButton))
.perform(ViewActions.click())
intended(expectedIntent)
} */
}
@After
fun after() {
......
......@@ -2,7 +2,12 @@ package com.h.pixeldroid
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.Color
import android.net.Uri
import android.os.Environment
import android.view.View.VISIBLE
import androidx.core.net.toUri
import androidx.test.core.app.ActivityScenario
import androidx.test.core.app.ApplicationProvider
import androidx.test.espresso.Espresso.onView
......@@ -17,15 +22,19 @@ import com.h.pixeldroid.db.InstanceDatabaseEntity
import com.h.pixeldroid.db.UserDatabaseEntity
import com.h.pixeldroid.testUtility.MockServer
import com.h.pixeldroid.utils.DBUtils
import kotlinx.android.synthetic.main.activity_post_creation.*
import org.hamcrest.Matchers.not
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.rules.Timeout
import org.junit.runner.RunWith
import java.io.File
@RunWith(AndroidJUnit4::class)
class PostCreationActivityTest {
private var testScenario: ActivityScenario<PostCreationActivity>? = null
private val mockServer = MockServer()
private lateinit var db: AppDatabase
......@@ -33,6 +42,13 @@ class PostCreationActivityTest {
@get:Rule
val globalTimeout: Timeout = Timeout.seconds(30)
private fun File.writeBitmap(bitmap: Bitmap) {
outputStream().use { out ->
bitmap.compress(Bitmap.CompressFormat.PNG, 85, out)
out.flush()
}
}
@Before
fun setup() {
val context = InstrumentationRegistry.getInstrumentation().targetContext
......@@ -59,11 +75,23 @@ class PostCreationActivityTest {
)
)
db.close()
val uri: Uri = Uri.parse("android.resource://com.h.pixeldroid/drawable/index")
val intent = Intent(context, PostCreationActivity::class.java)
.putExtra("picture_uri", uri)
ActivityScenario.launch<PostCreationActivity>(intent)
var uri: Uri = "".toUri()
val scenario = ActivityScenario.launch(ProfileActivity::class.java)
scenario.onActivity {
val image = Bitmap.createBitmap(500, 500, Bitmap.Config.ARGB_8888)
image.eraseColor(Color.GREEN)
val folder =
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DCIM)
if (!folder.exists()) {
folder.mkdir()
}
val file = File.createTempFile("temp_img", ".png", folder)
file.writeBitmap(image)
uri = file.toUri()
}
val intent = Intent(context, PostCreationActivity::class.java).putExtra("picture_uri", uri)
testScenario = ActivityScenario.launch(intent)
}
@Test
......@@ -72,4 +100,12 @@ class PostCreationActivityTest {
// should send on main activity
onView(withId(R.id.main_activity_main_linear_layout)).check(matches(isDisplayed()))
}
@Test
fun errorShown() {
testScenario!!.onActivity { a -> a.upload_error.visibility = VISIBLE }
onView(withId(R.id.retry_upload_button)).perform(click())
// should send on main activity
onView(withId(R.id.retry_upload_button)).check(matches(not(isDisplayed())))
}
}
\ No newline at end of file
......@@ -5,6 +5,7 @@ import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.net.Uri
import android.os.Bundle
import android.provider.OpenableColumns
import android.util.Log
import android.view.View.GONE
import android.view.View.VISIBLE
......@@ -12,13 +13,16 @@ import android.widget.Button
import android.widget.ImageView
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.core.net.toFile
import androidx.core.net.toUri
import com.bumptech.glide.Glide
import com.google.android.material.textfield.TextInputEditText
import com.h.pixeldroid.api.PixelfedAPI
import com.h.pixeldroid.db.UserDatabaseEntity
import com.h.pixeldroid.objects.Instance
import com.h.pixeldroid.objects.Status
import com.h.pixeldroid.utils.DBUtils
import com.mikepenz.iconics.Iconics
import io.reactivex.Observable
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.disposables.Disposable
......@@ -43,7 +47,7 @@ class PostCreationActivity : AppCompatActivity(){
private lateinit var accessToken: String
private lateinit var pixelfedAPI: PixelfedAPI
private lateinit var pictureFrame: ImageView
private lateinit var image: File
private lateinit var imageUri: Uri
private var user: UserDatabaseEntity? = null
private var listOfIds: List<String> = emptyList()
......@@ -54,14 +58,14 @@ class PostCreationActivity : AppCompatActivity(){
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Iconics.init(this)
setContentView(R.layout.activity_post_creation)
val imageUri: Uri = intent.getParcelableExtra("picture_uri")!!
saveImage(imageUri)
imageUri = intent.getParcelableExtra("picture_uri")!!
pictureFrame = findViewById(R.id.post_creation_picture_frame)
pictureFrame.setImageURI(image.toUri())
Glide.with(this).load(imageUri).into(pictureFrame)
val db = DBUtils.initDB(applicationContext)
user = db.userDao().getActiveUser()
......@@ -100,27 +104,7 @@ class PostCreationActivity : AppCompatActivity(){
override fun onDestroy() {
super.onDestroy()
//delete the temporary image
image.delete()
}
private fun saveImage(imageUri: Uri) {
try {
val stream = applicationContext.contentResolver
.openAssetFileDescriptor(imageUri, "r")!!
.createInputStream()
val bm = BitmapFactory.decodeStream(stream)
val bos = ByteArrayOutputStream()
bm.compress(Bitmap.CompressFormat.PNG, 0, bos)
image = File.createTempFile("temp_compressed_img", ".png", cacheDir)
val fos = FileOutputStream(image)
fos.write(bos.toByteArray())
fos.flush()
fos.close()
} catch (error: IOException) {
error.printStackTrace()
throw error
}
//image.delete()
}
private fun setDescription(): Boolean {
......@@ -137,11 +121,30 @@ class PostCreationActivity : AppCompatActivity(){
return true
}
private fun upload(){
val imagePart = ProgressRequestBody(image)
private fun upload() {
val imageInputStream = contentResolver.openInputStream(imageUri)!!
val size =
if(imageUri.toString().startsWith("content")) {
contentResolver.query(imageUri, null, null, null, null)
?.use { cursor ->
/*
* Get the column indexes of the data in the Cursor,
* move to the first row in the Cursor, get the data,
* and display it.
*/
val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE)
cursor.moveToFirst()
cursor.getLong(sizeIndex)
} ?: 0
} else {
imageUri.toFile().length()
}
val imagePart = ProgressRequestBody(imageInputStream, size)
val requestBody = MultipartBody.Builder()
.setType(MultipartBody.FORM)
.addFormDataPart("file", image.name, imagePart)
.addFormDataPart("file", System.currentTimeMillis().toString(), imagePart)
.build()
val sub = imagePart.progressSubject
......@@ -216,7 +219,7 @@ class PostCreationActivity : AppCompatActivity(){
}
class ProgressRequestBody(private val mFile: File) : RequestBody() {
class ProgressRequestBody(private val mFile: InputStream, private val length: Long) : RequestBody() {
private val getProgressSubject: PublishSubject<Float> = PublishSubject.create()
......@@ -232,17 +235,16 @@ class ProgressRequestBody(private val mFile: File) : RequestBody() {
@Throws(IOException::class)
override fun contentLength(): Long {
return mFile.length()
return length
}
@Throws(IOException::class)
override fun writeTo(sink: BufferedSink) {
val fileLength = contentLength()
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
val `in` = FileInputStream(mFile)
var uploaded: Long = 0
`in`.use {
mFile.use {
var read: Int
var lastProgressPercentUpdate = 0.0f
read = it.read(buffer)
......
......@@ -54,8 +54,6 @@ class CameraFragment : Fragment() {
private lateinit var container: ConstraintLayout
private lateinit var viewFinder: PreviewView
private lateinit var outputDirectory: File
private val REQUIRED_PERMISSIONS = arrayOf(Manifest.permission.CAMERA, Manifest.permission.READ_EXTERNAL_STORAGE)
private val PICK_IMAGE_REQUEST = 1
private val CAPTURE_IMAGE_REQUEST = 2
......@@ -138,9 +136,6 @@ class CameraFragment : Fragment() {
// Every time the orientation of device changes, update rotation for use cases
// Determine the output directory
outputDirectory = getGalleryDirectory(requireContext())
// Wait for the views to be properly laid out
viewFinder.post {
......@@ -166,13 +161,13 @@ class CameraFragment : Fragment() {
private fun bindCameraUseCases() {
// Get screen metrics used to setup camera for full screen resolution
val metrics = DisplayMetrics().also { viewFinder.display.getRealMetrics(it) }
val metrics = DisplayMetrics().also { viewFinder.display?.getRealMetrics(it) }
Log.d(TAG, "Screen metrics: ${metrics.widthPixels} x ${metrics.heightPixels}")
val screenAspectRatio = aspectRatio(metrics.widthPixels, metrics.heightPixels)
Log.d(TAG, "Preview aspect ratio: $screenAspectRatio")
val rotation = viewFinder.display.rotation
val rotation = viewFinder.display?.rotation ?: 0
// Bind the CameraProvider to the LifeCycleOwner
val cameraSelector = CameraSelector.Builder().requireLensFacing(lensFacing).build()
......@@ -324,7 +319,7 @@ class CameraFragment : Fragment() {
// Create output file to hold the image
val photoFile = File.createTempFile(
"${System.currentTimeMillis()}.jpg", null, context?.cacheDir
"cachedPhoto", ".png", context?.cacheDir
)
// Setup image capture metadata
......@@ -385,15 +380,5 @@ class CameraFragment : Fragment() {
private const val RATIO_4_3_VALUE = 4.0 / 3.0
private const val RATIO_16_9_VALUE = 16.0 / 9.0
/** Use external media if it is available, our app's file directory otherwise */
private fun getGalleryDirectory(context: Context): File {
val appContext = context.applicationContext
val mediaDir = context.externalMediaDirs.firstOrNull()?.let {
File(it, appContext.resources.getString(R.string.app_name)).apply { mkdirs() } }
return if (mediaDir != null && mediaDir.exists())
mediaDir else appContext.filesDir
}
}
}
\ No newline at end of file
......@@ -64,7 +64,7 @@ class EditImageFragment : Fragment(), SeekBar.OnSeekBarChangeListener {
}
R.id.seekbar_contrast -> {
val tempProgress = .10f * prog
listener!!.onSaturationChange(tempProgress)
listener!!.onContrastChange(tempProgress)
}
}
}
......
package com.h.pixeldroid.fragments
import android.graphics.Bitmap
import android.graphics.ImageDecoder
import android.os.Build
import android.os.Bundle
import android.provider.MediaStore
import android.util.Log
import android.util.TypedValue
import androidx.fragment.app.Fragment
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.graphics.decodeBitmap
import androidx.recyclerview.widget.DefaultItemAnimator
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
......@@ -51,10 +55,20 @@ class FilterListFragment : Fragment(), FilterListFragmentListener {
return view
}
fun displayImage(bitmap: Bitmap?) {
private fun displayImage(bitmap: Bitmap?) {
val r = Runnable {
val tbImage: Bitmap = (if (bitmap == null) {
MediaStore.Images.Media.getBitmap(requireActivity().contentResolver, PhotoEditActivity.URI.picture_uri)
// TODO: Shouldn't use deprecated API on newer versions of Android,
// but the proper way to do it seems to crash for OpenGL reasons
//if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
// ImageDecoder.decodeBitmap(
// ImageDecoder.createSource(requireActivity().contentResolver, PhotoEditActivity.imageUri!!))
//} else {
MediaStore.Images.Media.getBitmap(
requireActivity().contentResolver,
PhotoEditActivity.imageUri
)
//}
} else {
Bitmap.createScaledBitmap(bitmap, 100, 100, false)
})
......@@ -75,7 +89,8 @@ class FilterListFragment : Fragment(), FilterListFragmentListener {
val tbItem = ThumbnailItem()
tbItem.image = tbImage
tbItem.filterName = getString(R.string.normal_filter)
tbItem.filter.name = getString(R.string.normal_filter)
tbItem.filterName = tbItem.filter.name
ThumbnailsManager.addThumb(tbItem)
val filters = FilterPack.getFilterPack(requireActivity())
......
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/coordinator_edit"
......@@ -7,19 +7,91 @@
android:layout_height="match_parent"
tools:context=".PhotoEditActivity">
<com.google.android.material.appbar.AppBarLayout
<LinearLayout
android:orientation="vertical"
app:layout_behavior="@string/appbar_scrolling_view_behavior"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".PhotoEditActivity"
tools:showIn="@layout/activity_photo_edit"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
>
<ImageView
android:id="@+id/image_preview"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight=".70"
android:scaleType="centerInside"/>
<com.h.pixeldroid.utils.NonSwipeableViewPager
android:id="@+id/viewPager"
app:layout_behavior="@string/appbar_scrolling_view_behavior"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight=".22" />
<com.google.android.material.tabs.TabLayout
android:id="@+id/tabs"
app:tabGravity="fill"
app:tabMode="fixed"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight=".08"/>
</LinearLayout>
<ProgressBar
android:id="@+id/progressBarSaveFile"
style="?android:attr/progressBarStyleHorizontal"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:indeterminate="true"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/toolbar" />
<androidx.constraintlayout.widget.Guideline
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:theme="@style/AppTheme.AppBarOverlay">