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)