diff --git a/.github/workflows/build-tester-apk.yml b/.github/workflows/build-tester-apk.yml
index 5393911ffb..96c9395786 100644
--- a/.github/workflows/build-tester-apk.yml
+++ b/.github/workflows/build-tester-apk.yml
@@ -1,4 +1,4 @@
-name: Build unsigned apk
+name: Build apk
on:
workflow_dispatch:
@@ -7,15 +7,17 @@ on:
description: "Flavor to build"
type: choice
required: true
- default: "qa"
+ default: "original"
options:
+ - original
- qa
build_type:
description: "Type to build"
type: choice
required: true
- default: "Debug" # uppercase for gradlew clean assembleqaDebug
+ default: "Release" # uppercase for gradlew clean assembleoriginalRelease
options:
+ - Release
- Debug
version:
description: "Version to build (commit, branch or tag)"
@@ -27,7 +29,7 @@ permissions:
jobs:
build_apk:
- name: Build unsigned APK
+ name: Build APK
runs-on: ubuntu-latest
outputs:
artifact_name: ${{ steps.set_artifact.outputs.artifact_name }}
@@ -46,7 +48,7 @@ jobs:
echo "Commit hash: $COMMIT_HASH"
echo "commit_hash=$COMMIT_HASH" >> $GITHUB_OUTPUT
echo "commit_hash=$COMMIT_HASH" >> "$GITHUB_ENV"
-
+
- name: Setup JDK
uses: actions/setup-java@v5
with:
@@ -63,8 +65,24 @@ jobs:
restore-keys: |
${{ runner.os }}-gradle-
+ - name: Decode keystore
+ if: inputs.build_type == 'Release'
+ env:
+ KEYSTORE_B64: ${{ secrets.KEYSTORE_B64 }}
+ run: |
+ if [ -z "$KEYSTORE_B64" ]; then
+ echo "KEYSTORE_B64 secret is missing - add it under Settings > Secrets > Actions"
+ exit 1
+ fi
+ echo "$KEYSTORE_B64" | base64 -d > "$RUNNER_TEMP/release.jks"
+
- name: Build APK
run: ./gradlew clean assemble${{ inputs.build_flavor }}${{ inputs.build_type }} -PversionCode=${{ github.run_number }}
+ env:
+ OC_RELEASE_KEYSTORE: ${{ inputs.build_type == 'Release' && format('{0}/release.jks', runner.temp) || '' }}
+ OC_RELEASE_KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
+ OC_RELEASE_KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
+ OC_RELEASE_KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
- name: Set artifact name
id: set_artifact
@@ -72,10 +90,10 @@ jobs:
DATE=$(date +%Y%m%d_%H%M)
COMMIT=$(git rev-parse --short HEAD)
FLAVOR="${{ inputs.build_flavor }}"
- BUILD_TYPE="debug"
- NAME="OpenCloud-${{ inputs.build_flavor }}-${COMMIT}-${DATE}-${GITHUB_RUN_NUMBER}.apk"
+ BUILD_TYPE=$(echo "${{ inputs.build_type }}" | tr '[:upper:]' '[:lower:]')
+ NAME="OpenCloud-${FLAVOR}-${BUILD_TYPE}-${COMMIT}-${DATE}-${GITHUB_RUN_NUMBER}.apk"
- APK_PATH=$(find ./opencloudApp/build/outputs/apk/$FLAVOR/$BUILD_TYPE/ -name "*.apk")
+ APK_PATH=$(find ./opencloudApp/build/outputs/apk/$FLAVOR/$BUILD_TYPE/ -name "*.apk" | head -1)
echo "$APK_PATH"
if [ -z "$APK_PATH" ]; then
@@ -89,6 +107,10 @@ jobs:
# Publish as output
echo "artifact_name=$NAME" >> $GITHUB_OUTPUT
+ - name: Clean up keystore
+ if: always()
+ run: rm -f "$RUNNER_TEMP/release.jks"
+
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
@@ -99,10 +121,10 @@ jobs:
- name: Upload APK to release
uses: softprops/action-gh-release@v2
with:
- tag_name: build-tester-apk-${{ github.run_number }}
+ tag_name: build-apk-${{ github.run_number }}
target_commitish: ${{ env.commit_hash }}
- name: build-tester-apk build ${{ github.run_number }}
+ name: ${{ inputs.build_flavor }} ${{ inputs.build_type }} build ${{ github.run_number }}
files: ${{ steps.set_artifact.outputs.artifact_name }}
- prerelease: true
+ prerelease: ${{ inputs.build_type == 'Debug' }}
generate_release_notes: false
body: Built from ${{ env.commit_hash }}
diff --git a/opencloudApp/src/main/AndroidManifest.xml b/opencloudApp/src/main/AndroidManifest.xml
index 4ced565012..0691c1d12d 100644
--- a/opencloudApp/src/main/AndroidManifest.xml
+++ b/opencloudApp/src/main/AndroidManifest.xml
@@ -94,6 +94,11 @@
android:name=".presentation.settings.privacypolicy.PrivacyPolicyActivity"
android:label="@string/actionbar_privacy_policy" />
+
->
if (uiResult is UIResult.Success) {
- val builder = CustomTabsIntent.Builder().build()
- builder.launchUrl(
- requireActivity(),
- Uri.parse(uiResult.data)
- )
+ uiResult.data?.let { WebOfficeActivity.open(requireActivity(), it) }
} else if (uiResult is UIResult.Error) {
// Mimetypes not supported via open in web, send 500
if (uiResult.error is InstanceNotConfiguredException) {
diff --git a/opencloudApp/src/main/java/eu/opencloud/android/presentation/files/filelist/MainFileListFragment.kt b/opencloudApp/src/main/java/eu/opencloud/android/presentation/files/filelist/MainFileListFragment.kt
index b73419d087..673a0a4c1e 100644
--- a/opencloudApp/src/main/java/eu/opencloud/android/presentation/files/filelist/MainFileListFragment.kt
+++ b/opencloudApp/src/main/java/eu/opencloud/android/presentation/files/filelist/MainFileListFragment.kt
@@ -30,7 +30,6 @@ import android.content.Context
import android.content.Intent
import android.content.res.ColorStateList
import android.graphics.drawable.Drawable
-import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.view.LayoutInflater
@@ -47,7 +46,6 @@ import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.view.ActionMode
import androidx.appcompat.widget.SearchView
-import androidx.browser.customtabs.CustomTabsIntent
import androidx.core.content.ContextCompat
import androidx.core.content.res.ResourcesCompat
import androidx.core.view.isVisible
@@ -81,6 +79,7 @@ import eu.opencloud.android.domain.transfers.model.OCTransfer
import eu.opencloud.android.domain.transfers.model.TransferStatus
import eu.opencloud.android.domain.utils.Event
import eu.opencloud.android.extensions.addOpenInWebMenuOptions
+import eu.opencloud.android.presentation.weboffice.WebOfficeActivity
import eu.opencloud.android.extensions.collectLatestLifecycleFlow
import eu.opencloud.android.extensions.filterMenuOptions
import eu.opencloud.android.extensions.parseError
@@ -546,12 +545,8 @@ class MainFileListFragment : Fragment(),
if (it != null) {
val uiResult = it.peekContent()
if (uiResult is UIResult.Success) {
- browserOpened = true
- val builder = CustomTabsIntent.Builder().build()
- builder.launchUrl(
- requireActivity(),
- Uri.parse(uiResult.data)
- )
+ browserOpened = !WebOfficeActivity.isEditorPayload(uiResult.data)
+ uiResult.data?.let { WebOfficeActivity.open(requireActivity(), it) }
} else if (uiResult is UIResult.Error) {
// Mimetypes not supported via open in web, send 500
if (uiResult.error is InstanceNotConfiguredException) {
diff --git a/opencloudApp/src/main/java/eu/opencloud/android/presentation/weboffice/WebOfficeActivity.kt b/opencloudApp/src/main/java/eu/opencloud/android/presentation/weboffice/WebOfficeActivity.kt
new file mode 100644
index 0000000000..10ec4826ee
--- /dev/null
+++ b/opencloudApp/src/main/java/eu/opencloud/android/presentation/weboffice/WebOfficeActivity.kt
@@ -0,0 +1,204 @@
+/**
+ * openCloud Android client application
+ *
+ * Shows a web office editor (e.g. Euro-Office) full screen inside the app,
+ * instead of handing the user off to a browser tab.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ */
+
+package eu.opencloud.android.presentation.weboffice
+
+import android.annotation.SuppressLint
+import android.app.Activity
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import android.os.Bundle
+import android.view.View
+import android.view.ViewGroup
+import android.webkit.CookieManager
+import android.webkit.ValueCallback
+import android.webkit.WebChromeClient
+import android.webkit.WebResourceRequest
+import android.webkit.WebSettings
+import android.webkit.WebView
+import android.webkit.WebViewClient
+import android.widget.FrameLayout
+import android.widget.ProgressBar
+import androidx.activity.OnBackPressedCallback
+import androidx.appcompat.app.AppCompatActivity
+import androidx.browser.customtabs.CustomTabsIntent
+import androidx.core.view.ViewCompat
+import androidx.core.view.WindowInsetsCompat
+import androidx.core.view.updatePadding
+import org.json.JSONObject
+import timber.log.Timber
+import java.net.URLEncoder
+
+class WebOfficeActivity : AppCompatActivity() {
+
+ private lateinit var webView: WebView
+ private lateinit var progress: ProgressBar
+ private var fileChooserCallback: ValueCallback>? = null
+ private var editorHost: String? = null
+
+ @SuppressLint("SetJavaScriptEnabled")
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ val payload = intent.getStringExtra(EXTRA_PAYLOAD)
+ if (payload == null) {
+ finish()
+ return
+ }
+
+ val root = FrameLayout(this)
+ webView = WebView(this)
+ progress = ProgressBar(this, null, android.R.attr.progressBarStyleHorizontal).apply {
+ isIndeterminate = true
+ }
+ root.addView(webView, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT))
+ root.addView(progress, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT))
+ setContentView(root)
+ supportActionBar?.hide()
+
+ // keep the editor out from under the status / navigation bars
+ ViewCompat.setOnApplyWindowInsetsListener(root) { view, insets ->
+ val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.ime())
+ view.updatePadding(top = bars.top, bottom = bars.bottom, left = bars.left, right = bars.right)
+ WindowInsetsCompat.CONSUMED
+ }
+
+ with(webView.settings) {
+ javaScriptEnabled = true
+ domStorageEnabled = true
+ databaseEnabled = true
+ mediaPlaybackRequiresUserGesture = false
+ cacheMode = WebSettings.LOAD_DEFAULT
+ setSupportZoom(false)
+ }
+ CookieManager.getInstance().setAcceptThirdPartyCookies(webView, true)
+
+ webView.webViewClient = object : WebViewClient() {
+ override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {
+ val url = request.url
+ // stay inside the editor; anything else (help links, hyperlinks in a doc) goes to a browser tab
+ return if (editorHost != null && url.host != editorHost && request.isForMainFrame) {
+ CustomTabsIntent.Builder().build().launchUrl(this@WebOfficeActivity, url)
+ true
+ } else {
+ false
+ }
+ }
+
+ override fun onPageFinished(view: WebView, url: String) {
+ progress.visibility = View.GONE
+ }
+ }
+
+ webView.webChromeClient = object : WebChromeClient() {
+ // "insert image" etc. in the editor
+ override fun onShowFileChooser(
+ view: WebView,
+ callback: ValueCallback>,
+ params: FileChooserParams
+ ): Boolean {
+ fileChooserCallback?.onReceiveValue(null)
+ fileChooserCallback = callback
+ return try {
+ @Suppress("DEPRECATION")
+ startActivityForResult(params.createIntent(), REQUEST_FILE_CHOOSER)
+ true
+ } catch (e: Exception) {
+ fileChooserCallback = null
+ false
+ }
+ }
+ }
+
+ onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
+ override fun handleOnBackPressed() {
+ // the editor is a single page app: back closes it (changes autosave server-side)
+ finish()
+ }
+ })
+
+ if (savedInstanceState != null) {
+ webView.restoreState(savedInstanceState)
+ } else {
+ loadEditor(payload)
+ }
+ }
+
+ private fun loadEditor(payload: String) {
+ try {
+ val json = JSONObject(payload)
+ val appUrl = json.getString("app_url")
+ val method = json.optString("method", "GET").uppercase()
+ val params = json.optJSONObject("form_parameters")
+ editorHost = Uri.parse(appUrl).host
+
+ val form = buildString {
+ params?.keys()?.forEach { key ->
+ if (isNotEmpty()) append('&')
+ append(URLEncoder.encode(key, "UTF-8")).append('=')
+ append(URLEncoder.encode(params.getString(key), "UTF-8"))
+ }
+ }
+
+ if (method == "POST") {
+ webView.postUrl(appUrl, form.toByteArray())
+ } else {
+ val sep = if (appUrl.contains('?')) '&' else '?'
+ webView.loadUrl(if (form.isEmpty()) appUrl else "$appUrl$sep$form")
+ }
+ } catch (e: Exception) {
+ Timber.e(e, "Could not open web office editor")
+ finish()
+ }
+ }
+
+ @Deprecated("Deprecated in Java")
+ override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
+ super.onActivityResult(requestCode, resultCode, data)
+ if (requestCode == REQUEST_FILE_CHOOSER) {
+ fileChooserCallback?.onReceiveValue(
+ if (resultCode == Activity.RESULT_OK) WebChromeClient.FileChooserParams.parseResult(resultCode, data) else null
+ )
+ fileChooserCallback = null
+ }
+ }
+
+ override fun onSaveInstanceState(outState: Bundle) {
+ super.onSaveInstanceState(outState)
+ webView.saveState(outState)
+ }
+
+ override fun onDestroy() {
+ if (::webView.isInitialized) {
+ (webView.parent as? ViewGroup)?.removeView(webView)
+ webView.destroy()
+ }
+ super.onDestroy()
+ }
+
+ companion object {
+ private const val EXTRA_PAYLOAD = "web_office_payload"
+ private const val REQUEST_FILE_CHOOSER = 4711
+
+ /** true if [data] is an /app/open payload (JSON) rather than a plain browser URL */
+ fun isEditorPayload(data: String?): Boolean = data?.trimStart()?.startsWith("{") == true
+
+ /** Opens the editor in-app for a JSON payload, or falls back to a browser tab for a plain URL. */
+ fun open(context: Context, data: String) {
+ if (isEditorPayload(data)) {
+ context.startActivity(Intent(context, WebOfficeActivity::class.java).putExtra(EXTRA_PAYLOAD, data))
+ } else {
+ CustomTabsIntent.Builder().build().launchUrl(context, Uri.parse(data))
+ }
+ }
+ }
+}
diff --git a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/appregistry/GetUrlToOpenInWebRemoteOperation.kt b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/appregistry/GetUrlToOpenInWebRemoteOperation.kt
index a1e9c9f76c..af89327fda 100644
--- a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/appregistry/GetUrlToOpenInWebRemoteOperation.kt
+++ b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/appregistry/GetUrlToOpenInWebRemoteOperation.kt
@@ -31,11 +31,9 @@ import eu.opencloud.android.lib.common.network.WebdavUtils
import eu.opencloud.android.lib.common.operations.RemoteOperation
import eu.opencloud.android.lib.common.operations.RemoteOperationResult
import eu.opencloud.android.lib.common.operations.RemoteOperationResult.ResultCode
-import com.squareup.moshi.JsonAdapter
-import com.squareup.moshi.JsonClass
-import com.squareup.moshi.Moshi
import okhttp3.FormBody
import okhttp3.RequestBody
+import org.json.JSONObject
import timber.log.Timber
import java.net.URL
import java.util.concurrent.TimeUnit
@@ -63,10 +61,14 @@ class GetUrlToOpenInWebRemoteOperation(
Timber.d("Open in web for file: $fileId - $status${if (!isSuccess(status)) "(FAIL)" else ""}")
if (isSuccess(status)) RemoteOperationResult(ResultCode.OK).apply {
- val moshi = Moshi.Builder().build()
- val adapter: JsonAdapter = moshi.adapter(OpenInWebResponse::class.java)
-
- data = postMethod.getResponseBodyAsString()?.let { adapter.fromJson(it)!!.uri }
+ // Two possible endpoints:
+ // - open_web_url ("/app/open-with-web") answers {"uri": "..."} -> a browser URL
+ // - open_url ("/app/open") answers {"app_url": ..., "method": ..., "form_parameters": {...}}
+ // -> we hand the raw JSON to the in-app editor, which loads the editor directly.
+ data = postMethod.getResponseBodyAsString()?.let { body ->
+ val json = JSONObject(body)
+ if (json.has("app_url")) body else json.getString("uri")
+ }
}
else RemoteOperationResult(postMethod).apply { data = "" }
@@ -86,17 +88,17 @@ class GetUrlToOpenInWebRemoteOperation(
FormBody.Builder()
.add(PARAM_FILE_ID, fileId)
.add(PARAM_APP_NAME, appName)
+ // ask the office app for its touch-optimized editor (honored when the server enables it)
+ .add(PARAM_MOBILE, "1")
.build()
companion object {
const val PARAM_FILE_ID = "file_id"
const val PARAM_APP_NAME = "app_name"
+ const val PARAM_MOBILE = "mobile"
}
}
- @JsonClass(generateAdapter = true)
- data class OpenInWebResponse(val uri: String)
-
companion object {
/**
* Maximum time to wait for a response from the server in milliseconds.
diff --git a/opencloudDomain/src/main/java/eu/opencloud/android/domain/appregistry/usecases/GetUrlToOpenInWebUseCase.kt b/opencloudDomain/src/main/java/eu/opencloud/android/domain/appregistry/usecases/GetUrlToOpenInWebUseCase.kt
index eb4f235882..f83bde8249 100644
--- a/opencloudDomain/src/main/java/eu/opencloud/android/domain/appregistry/usecases/GetUrlToOpenInWebUseCase.kt
+++ b/opencloudDomain/src/main/java/eu/opencloud/android/domain/appregistry/usecases/GetUrlToOpenInWebUseCase.kt
@@ -29,7 +29,10 @@ class GetUrlToOpenInWebUseCase(
override fun run(params: Params): String {
val capabilities = capabilitiesRepository.getStoredCapabilities(params.accountName)
- val openInWebUrl = capabilities?.filesAppProviders?.openWebUrl
+ // Prefer the direct open endpoint so the editor can be shown inside the app;
+ // fall back to the web UI URL (opened in a browser tab) if the server doesn't offer it.
+ val openInWebUrl = capabilities?.filesAppProviders?.openUrl?.takeIf { it.isNotBlank() }
+ ?: capabilities?.filesAppProviders?.openWebUrl
requireNotNull(openInWebUrl)