Skip to content
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2025 Philipp Hasper <vcs@hasper.info>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

package com.nextcloud.test

import android.view.View
import android.widget.TextView
import org.hamcrest.Description
import org.hamcrest.Matcher
import org.hamcrest.TypeSafeMatcher

fun withSelectedText(expected: String): Matcher<View> = object : TypeSafeMatcher<View>() {
override fun describeTo(description: Description) {
description.appendText("with selected text \"$expected\"")
}

@Suppress("ReturnCount")
override fun matchesSafely(view: View): Boolean {
if (view !is TextView) return false
val text = view.text?.toString() ?: ""
val s = view.selectionStart
val e = view.selectionEnd
@Suppress("ComplexCondition")
if (s < 0 || e < 0 || s > e || e > text.length) return false
return text.substring(s, e) == expected
}
}
12 changes: 8 additions & 4 deletions app/src/androidTest/java/com/owncloud/android/AbstractIT.java
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,9 @@
import java.io.FileWriter;
import java.io.IOException;
import java.io.InputStream;
import java.util.Arrays;
import java.util.Collection;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.Optional;
Expand Down Expand Up @@ -234,17 +236,19 @@ protected Account[] getAllAccounts() {
return AccountManager.get(targetContext).getAccounts();
}

protected static void createDummyFiles() throws IOException {
protected static List<File> createDummyFiles() throws IOException {
File tempPath = new File(FileStorageUtils.getTemporalPath(account.name));
if (!tempPath.exists()) {
assertTrue(tempPath.mkdirs());
}

assertTrue(tempPath.exists());

createFile("empty.txt", 0);
createFile("nonEmpty.txt", 100);
createFile("chunkedFile.txt", 500000);
return Arrays.asList(
createFile("empty.txt", 0),
createFile("nonEmpty.txt", 100),
createFile("chunkedFile.txt", 500000)
);
}

protected static File getDummyFile(String name) throws IOException {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,30 +1,76 @@
/*
* Nextcloud - Android Client
*
* SPDX-FileCopyrightText: 2025 Philipp Hasper <vcs@hasper.info>
* SPDX-FileCopyrightText: 2025 Alper Ozturk <alper.ozturk@nextcloud.com>
* SPDX-FileCopyrightText: 2022 Tobias Kaminsky <tobias@kaminsky.me>
* SPDX-FileCopyrightText: 2022 Nextcloud GmbH
* SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only
*/
package com.owncloud.android.ui.activity

import android.content.Intent
import android.net.Uri
import android.view.KeyEvent
import androidx.test.core.app.launchActivity
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.ViewMatchers.hasDescendant
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.isEnabled
import androidx.test.espresso.matcher.ViewMatchers.isRoot
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.espresso.matcher.ViewMatchers.withText
import com.facebook.testing.screenshot.internal.TestNameDetector
import com.nextcloud.client.preferences.AppPreferencesImpl
import com.nextcloud.test.GrantStoragePermissionRule
import com.nextcloud.test.withSelectedText
import com.nextcloud.utils.extensions.removeFileExtension
import com.owncloud.android.AbstractIT
import com.owncloud.android.R
import com.owncloud.android.datamodel.OCFile
import com.owncloud.android.utils.ScreenshotTest
import org.hamcrest.Matchers.not
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TestRule
import java.io.File

class ReceiveExternalFilesActivityIT : AbstractIT() {
private val testClassName = "com.owncloud.android.ui.activity.ReceiveExternalFilesActivityIT"

@get:Rule
var storagePermissionRule: TestRule = GrantStoragePermissionRule.grant()

lateinit var mainFolder: OCFile
lateinit var subFolder: OCFile
lateinit var existingImageFile: OCFile

@Before
fun setupFolderAndFileStructure() {
// Create folders with the necessary permissions and another test file
mainFolder = OCFile("/folder/").apply {
permissions = OCFile.PERMISSION_CAN_CREATE_FILE_AND_FOLDER
setFolder()
fileDataStorageManager.saveNewFile(this)
}
subFolder = OCFile("${mainFolder.remotePath}sub folder/").apply {
permissions = OCFile.PERMISSION_CAN_CREATE_FILE_AND_FOLDER
setFolder()
fileDataStorageManager.saveNewFile(this)
}
existingImageFile = OCFile("${mainFolder.remotePath}Existing Image File.jpg").apply {
fileDataStorageManager.saveNewFile(this)
}
}

@Test
@ScreenshotTest
fun open() {
// Screenshot name must be constructed outside of the scenario, otherwise it will not be reliably detected
val screenShotName = TestNameDetector.getTestClass() + "_" + TestNameDetector.getTestName()
launchActivity<ReceiveExternalFilesActivity>().use { scenario ->
val screenShotName = createName(testClassName + "_" + "open", "")
onView(isRoot()).check(matches(isDisplayed()))

scenario.onActivity { sut ->
Expand All @@ -40,4 +86,161 @@ class ReceiveExternalFilesActivityIT : AbstractIT() {
open()
removeAccount(secondAccount)
}

fun createSendIntent(file: File): Intent = Intent(targetContext, ReceiveExternalFilesActivity::class.java).apply {
action = Intent.ACTION_SEND
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
putExtra(Intent.EXTRA_STREAM, Uri.fromFile(file))
}

fun createSendIntent(files: Iterable<File>): Intent =
Intent(targetContext, ReceiveExternalFilesActivity::class.java).apply {
action = Intent.ACTION_SEND_MULTIPLE
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
putParcelableArrayListExtra(Intent.EXTRA_STREAM, ArrayList(files.map { Uri.fromFile(it) }))
}

@Test
fun renameSingleFileUpload() {
val imageFile = getDummyFile("image.jpg")
val intent = createSendIntent(imageFile)

// Store the folder in preferences, so the activity starts from there.
@Suppress("DEPRECATION")
val preferences = AppPreferencesImpl.fromContext(targetContext)
preferences.setLastUploadPath(mainFolder.remotePath)

launchActivity<ReceiveExternalFilesActivity>(intent).use {
val expectedMainFolderTitle = (getCurrentActivity() as ToolbarActivity).getActionBarTitle(mainFolder, false)
// Verify that the test starts in the expected folder. If this fails, change the setup calls above
onView(withId(R.id.toolbar))
.check(matches(hasDescendant(withText(expectedMainFolderTitle))))

onView(withText(R.string.uploader_btn_upload_text))
.check(matches(isDisplayed()))
.check(matches(isEnabled()))

// Test the pre-selection behavior (filename, but without extension, shall be selected)
onView(withId(R.id.user_input))
.check(matches(withText(imageFile.name)))
.perform(ViewActions.click())
.check(matches(withSelectedText(imageFile.name.removeFileExtension())))

// Set a new file name
val secondFileName = "New filename.jpg"
onView(withId(R.id.user_input))
.perform(ViewActions.typeTextIntoFocusedView(secondFileName.removeFileExtension()))
.check(matches(withText(secondFileName)))
// Leave the field and come back to verify the pre-selection behavior correctly handles the new name
.perform(ViewActions.pressKey(KeyEvent.KEYCODE_TAB))
.perform(ViewActions.click())
.check(matches(withSelectedText(secondFileName.removeFileExtension())))
onView(withText(R.string.uploader_btn_upload_text))
.check(matches(isDisplayed()))
.check(matches(isEnabled()))

// Set a file name without file extension
val thirdFileName = "No extension"
onView(withId(R.id.user_input))
.perform(ViewActions.clearText())
.perform(ViewActions.typeTextIntoFocusedView(thirdFileName))
.check(matches(withText(thirdFileName)))
// Leave the field and come back to verify the pre-selection behavior correctly handles the new name
.perform(ViewActions.pressKey(KeyEvent.KEYCODE_TAB))
.perform(ViewActions.click())
.check(matches(withSelectedText(thirdFileName)))
onView(withText(R.string.uploader_btn_upload_text))
.check(matches(isDisplayed()))
.check(matches(isEnabled()))

// Test an invalid filename. Note: as the user is null, the capabilities are also null, so the name checker
// will not reject any special characters like '/'. So we only test empty and an existing file name
onView(withId(R.id.user_input))
.perform(ViewActions.clearText())
.check(matches(withText("")))
onView(withText(R.string.uploader_btn_upload_text))
.check(matches(isDisplayed()))
.check(matches(not(isEnabled())))
onView(withId(R.id.user_input))
.perform(ViewActions.click())
.perform(ViewActions.typeTextIntoFocusedView(existingImageFile.fileName))
.check(matches(withText(existingImageFile.fileName)))
onView(withText(R.string.uploader_btn_upload_text))
.check(matches(isDisplayed()))
.check(matches(not(isEnabled())))

val fourthFileName = "New file name.jpg"
onView(withId(R.id.user_input))
.perform(ViewActions.click())
.perform(ViewActions.clearText())
.perform(ViewActions.typeTextIntoFocusedView(fourthFileName))
.check(matches(withText(fourthFileName)))
onView(withText(R.string.uploader_btn_upload_text))
.check(matches(isDisplayed()))
.check(matches(isEnabled()))

// Enter the subfolder and verify that the text stays intact
val expectedSubFolderTitle = (getCurrentActivity() as ToolbarActivity).getActionBarTitle(subFolder, false)
onView(withText(expectedSubFolderTitle))
.perform(ViewActions.click())
onView(withId(R.id.toolbar))
.check(matches(hasDescendant(withText(expectedSubFolderTitle))))
onView(withId(R.id.user_input))
.check(matches(withText(fourthFileName)))
.perform(ViewActions.click())
.check(matches(withSelectedText(fourthFileName.removeFileExtension())))

// Set a new, shorter file name
val fifthFileName = "short.jpg"
onView(withId(R.id.user_input))
.perform(ViewActions.typeTextIntoFocusedView(fifthFileName.removeFileExtension()))
.check(matches(withText(fifthFileName)))

// Start the upload, so the folder is stored in the preferences.
// Even though the upload is expected to fail because the backend is not mocked (yet?)
onView(withText(R.string.uploader_btn_upload_text))
.check(matches(isDisplayed()))
.check(matches(isEnabled()))
.perform(ViewActions.click())
}

// Start a new file receive flow. Should now start in the sub folder, but with the original filename again
launchActivity<ReceiveExternalFilesActivity>(intent).use {
val expectedMainFolderTitle = (getCurrentActivity() as ToolbarActivity).getActionBarTitle(subFolder, false)
onView(withId(R.id.toolbar))
.check(matches(hasDescendant(withText(expectedMainFolderTitle))))

onView(withText(R.string.uploader_btn_upload_text))
.check(matches(isDisplayed()))
.check(matches(isEnabled()))

onView(withId(R.id.user_input))
.check(matches(withText(imageFile.name)))
}
}

@Test
fun noRenameForMultiUpload() {
val testFiles = createDummyFiles()
val intent = createSendIntent(testFiles)

// Store the folder in preferences, so the activity starts from there.
@Suppress("DEPRECATION")
val preferences = AppPreferencesImpl.fromContext(targetContext)
preferences.setLastUploadPath(mainFolder.remotePath)

launchActivity<ReceiveExternalFilesActivity>(intent).use {
val expectedMainFolderTitle = (getCurrentActivity() as ToolbarActivity).getActionBarTitle(mainFolder, false)
// Verify that the test starts in the expected folder. If this fails, change the setup calls above
onView(withId(R.id.toolbar))
.check(matches(hasDescendant(withText(expectedMainFolderTitle))))

onView(withText(R.string.uploader_btn_upload_text))
.check(matches(isDisplayed()))
.check(matches(isEnabled()))

onView(withId(R.id.user_input))
.check(matches(not(isDisplayed())))
}
}
}
20 changes: 10 additions & 10 deletions app/src/main/java/com/owncloud/android/datamodel/OCFile.java
Original file line number Diff line number Diff line change
Expand Up @@ -47,16 +47,16 @@
public class OCFile implements Parcelable, Comparable<OCFile>, ServerFileInterface {

public final static String PERMISSION_CAN_RESHARE = "R";
private final static String PERMISSION_SHARED = "S";
private final static String PERMISSION_MOUNTED = "M";
private final static String PERMISSION_CAN_CREATE_FILE_INSIDE_FOLDER = "C";
private final static String PERMISSION_CAN_CREATE_FOLDER_INSIDE_FOLDER = "K";
private final static String PERMISSION_CAN_READ = "G";
private final static String PERMISSION_CAN_WRITE = "W";
private final static String PERMISSION_CAN_DELETE_OR_LEAVE_SHARE = "D";
private final static String PERMISSION_CAN_RENAME = "N";
private final static String PERMISSION_CAN_MOVE = "V";
private final static String PERMISSION_CAN_CREATE_FILE_AND_FOLDER = PERMISSION_CAN_CREATE_FILE_INSIDE_FOLDER + PERMISSION_CAN_CREATE_FOLDER_INSIDE_FOLDER;
public final static String PERMISSION_SHARED = "S";
public final static String PERMISSION_MOUNTED = "M";
public final static String PERMISSION_CAN_CREATE_FILE_INSIDE_FOLDER = "C";
public final static String PERMISSION_CAN_CREATE_FOLDER_INSIDE_FOLDER = "K";
public final static String PERMISSION_CAN_READ = "G";
public final static String PERMISSION_CAN_WRITE = "W";
public final static String PERMISSION_CAN_DELETE_OR_LEAVE_SHARE = "D";
public final static String PERMISSION_CAN_RENAME = "N";
public final static String PERMISSION_CAN_MOVE = "V";
public final static String PERMISSION_CAN_CREATE_FILE_AND_FOLDER = PERMISSION_CAN_CREATE_FILE_INSIDE_FOLDER + PERMISSION_CAN_CREATE_FOLDER_INSIDE_FOLDER;

private final static int MAX_FILE_SIZE_FOR_IMMEDIATE_PREVIEW_BYTES = 1024000;

Expand Down
Loading
Loading