From 155baeba00ba01ac94647181b48c5ec1ae845f67 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Wed, 2 Sep 2026 11:04:25 +0200 Subject: [PATCH 1/4] refactor Signed-off-by: alperozturk96 # Conflicts: # app/schemas/com.nextcloud.client.database.NextcloudDatabase/103.json # app/src/main/java/com/nextcloud/client/database/NextcloudDatabase.kt --- .../android/utils/EncryptionUtilsV2IT.kt | 248 +++++++++---- .../nextcloud/client/database/dao/FileDao.kt | 3 + .../client/database/entity/FileEntity.kt | 4 +- .../com/nextcloud/client/di/AppModule.java | 26 +- .../OfflineOperationsWorker.kt | 11 +- .../utils/e2ee/E2EEActionResolver.kt | 90 +++++ .../utils/e2ee/E2EEDialogPresenter.kt | 50 +++ .../nextcloud/utils/e2ee/E2EEKeyInspector.kt | 149 ++++++++ .../nextcloud/utils/e2ee/model/E2EEAction.kt | 14 + .../nextcloud/utils/e2ee/model/E2EEDialog.kt | 33 ++ .../utils/e2ee/model/E2EEKeyCheck.kt | 111 ++++++ .../ConnectivityServiceExtensions.kt | 21 ++ .../utils/extensions/OCFileExtensions.kt | 8 + .../OCFileListFragmentExtensions.kt | 84 +++++ .../datamodel/FileDataStorageManager.java | 30 ++ .../owncloud/android/datamodel/OCFile.java | 12 + .../com/owncloud/android/db/ProviderMeta.java | 2 + .../android/files/FileMenuFilter.java | 26 ++ .../operations/RefreshFolderOperation.java | 1 + .../ui/activity/SetupEncryptionActivity.kt | 11 +- .../model/DownloadKeyResult.kt | 4 + .../android/ui/events/EncryptionEvent.kt | 32 +- .../fragment/EncryptedFolderClickHandler.kt | 149 ++++++++ .../android/ui/fragment/FolderEncryption.kt | 162 +++++++++ .../ui/fragment/OCFileListFragment.java | 333 +++--------------- .../ui/helpers/FileOperationsHelper.java | 6 +- .../owncloud/android/utils/DisplayUtils.java | 33 ++ .../android/utils/EncryptionUtils.java | 16 +- app/src/main/res/values/strings.xml | 25 +- .../MockOCFileListAdapterDataProvider.kt | 3 +- 30 files changed, 1306 insertions(+), 391 deletions(-) create mode 100644 app/src/main/java/com/nextcloud/utils/e2ee/E2EEActionResolver.kt create mode 100644 app/src/main/java/com/nextcloud/utils/e2ee/E2EEDialogPresenter.kt create mode 100644 app/src/main/java/com/nextcloud/utils/e2ee/E2EEKeyInspector.kt create mode 100644 app/src/main/java/com/nextcloud/utils/e2ee/model/E2EEAction.kt create mode 100644 app/src/main/java/com/nextcloud/utils/e2ee/model/E2EEDialog.kt create mode 100644 app/src/main/java/com/nextcloud/utils/e2ee/model/E2EEKeyCheck.kt create mode 100644 app/src/main/java/com/nextcloud/utils/extensions/ConnectivityServiceExtensions.kt create mode 100644 app/src/main/java/com/nextcloud/utils/extensions/OCFileListFragmentExtensions.kt create mode 100644 app/src/main/java/com/owncloud/android/ui/fragment/EncryptedFolderClickHandler.kt create mode 100644 app/src/main/java/com/owncloud/android/ui/fragment/FolderEncryption.kt diff --git a/app/src/androidTest/java/com/owncloud/android/utils/EncryptionUtilsV2IT.kt b/app/src/androidTest/java/com/owncloud/android/utils/EncryptionUtilsV2IT.kt index bd196e84da1c..f50b06fcd69e 100644 --- a/app/src/androidTest/java/com/owncloud/android/utils/EncryptionUtilsV2IT.kt +++ b/app/src/androidTest/java/com/owncloud/android/utils/EncryptionUtilsV2IT.kt @@ -28,6 +28,7 @@ import com.owncloud.android.operations.RefreshFolderOperation import com.owncloud.android.util.EncryptionTestIT import junit.framework.TestCase.assertEquals import junit.framework.TestCase.assertTrue +import org.junit.Assert.assertFalse import org.junit.Assert.assertNotEquals import org.junit.Test @@ -645,76 +646,183 @@ class EncryptionUtilsV2IT : EncryptionIT() { assertEquals("this is a test.\n", gunzip) } -// @Test -// fun validate() { -// // ALEX -// val metadata1 = """{ -// "metadata": { -// "authenticationTag": "zMozev5R09UopLrq7Je1lw==", -// "ciphertext": "j0OBtUrEt4IveGiexjmGK7eKEaWrY70ZkteA5KxHDaZT/t2wwGy9j2FPQGpqXnW6OO3iAYPNgwFikI1smnfNvqdxzVDvhavl/IXa9Kg2niWyqK3D9zpz0YD6mDvl0XsOgTNVyGXNVREdWgzGEERCQoyHI1xowt/swe3KCXw+lf+XPF/t1PfHv0DiDVk70AeWGpPPPu6yggAIxB4Az6PEZhaQWweTC0an48l2FHj2MtB2PiMHtW2v7RMuE8Al3PtE4gOA8CMFrB+Npy6rKcFCXOgTZm5bp7q+J1qkhBDbiBYtvdsYujJ52Xa5SifTpEhGeWWLFnLLgPAQ8o6bXcWOyCoYfLfp4Jpft/Y7H8qzHbPewNSyD6maEv+xljjfU7hxibbszz5A4JjMdQy2BDGoTmJx7Mas+g6l6ZuHLVbdmgQOvD3waJBy6rOg0euux0Cn4bB4bIFEF2KvbhdGbY1Uiq9DYa7kEmSEnlcAYaHyroTkDg4ew7ER0vIBBMzKM3r+UdPVKKS66uyXtZc=", -// "nonce": "W+lxQJeGq7XAJiGfcDohkg==" -// }, -// "users": [{ -// "certificate": "-----BEGIN CERTIFICATE-----\nMIIDkDCCAnigAwIBAgIBADANBgkqhkiG9w0BAQUFADBhMQswCQYDVQQGEwJERTEb\nMBkGA1UECAwSQmFkZW4tV3VlcnR0ZW1iZXJnMRIwEAYDVQQHDAlTdHV0dGdhcnQx\nEjAQBgNVBAoMCU5leHRjbG91ZDENMAsGA1UEAwwEam9objAeFw0yMzA3MTQwNzM0\nNTZaFw00MzA3MDkwNzM0NTZaMGExCzAJBgNVBAYTAkRFMRswGQYDVQQIDBJCYWRl\nbi1XdWVydHRlbWJlcmcxEjAQBgNVBAcMCVN0dXR0Z2FydDESMBAGA1UECgwJTmV4\ndGNsb3VkMQ0wCwYDVQQDDARqb2huMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB\nCgKCAQEA7j3Er5YahJT0LAnSRLhpqbRo+E1AVnt98rvp3DmEfBHNzNB+DS9IBDkS\nSXM/YtfAci6Tcw8ujVBjrZX/WEmrf8ynQHxYmSaJSnP8uAT306/MceZpdpruEc9/\nS10a7vp54Zbld4NYdmfS71oVFVKgM7c/Vthx+rgu48fuxzbWAvVYLFcx47hz0DJT\nnjz2Za/R68uXpxfz7J9uEXYiqsAs/FobDsLZluT3RyywVRwKBed1EZxUeLIJiyxp\nUthhGfIb8b3Vf9jZoUVi3m5gmc4spJQHvYAkfZYHzd9ras8jBu1abQRxcu2CYnVo\n6Y0mTxhKhQS/n5gjv3ExiQF3wp/XYwIDAQABo1MwUTAdBgNVHQ4EFgQUmTeILVuB\ntv70fTGkXWGAueDp5kAwHwYDVR0jBBgwFoAUmTeILVuBtv70fTGkXWGAueDp5kAw\nDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQUFAAOCAQEAyVtq9XAvW7nxSW/8\nhp30z6xbzGiuviXhy/Jo91VEa8IRsWCCn3OmDFiVduTEowx76tf8clJP0gk7Pozi\n6dg/7Fin+FqQGXfCk8bLAh9gXKAikQ2GK8yRN3slRFwYC2mm23HrLdKXZHUqJcpB\nMz2zsSrOGPj1YsYOl/U8FU6KA7Yj7U3q7kDMYTAgzUPZAH+d1DISGWpZsMa0RYid\nvigCCLByiccmS/Co4Sb1esF58H+YtV5+nFBRwx881U2g2TgDKF1lPMK/y3d8B8mh\nUtW+lFxRpvyNUDpsMjOErOrtNFEYbgoUJLtqwBMmyGR+nmmh6xna331QWcRAmw0P\nnDO4ew==\n-----END CERTIFICATE-----\n", -// "encryptedMetadataKey": "HVT49bYmwXbGs/dJ2avgU9unrKnPf03MYUI5ZysSR1Bz5pqz64gzH2GBAuUJ+Q4VmHtEfcMaWW7VXgzfCQv5xLBrk+RSgcLOKnlIya8jaDlfttWxbe8jJK+/0+QVPOc6ycA/t5HNCPg09hzj+gnb2L89UHxL5accZD0iEzb5cQbGrc/N6GthjgGrgFKtFf0HhDVplUr+DL9aTyKuKLBPjrjuZbv8M6ZfXO93mOMwSZH3c3rwDUHb/KEaTR/Og4pWQmrqr1VxGLqeV/+GKWhzMYThrOZAUz+5gsbckU2M5V9i+ph0yBI5BjOZVhNuDwW8yP8WtyRJwQc+UBRei/RGBQ==", -// "userId": "john" -// }], -// "version": "2" -// } -// -// """ -// -// val signature1 = -// "ewogICAgIm1ldGFkYXRhIjogewogICAgICAgICJhdXRoZW50aWNhdGlvblRhZyI6ICJ6TW96ZXY1UjA5VW9wTHJxN0plMWx3PT0iLAogICAgICAgICJjaXBoZXJ0ZXh0IjogImowT0J0VXJFdDRJdmVHaWV4am1HSzdlS0VhV3JZNzBaa3RlQTVLeEhEYVpUL3Qyd3dHeTlqMkZQUUdwcVhuVzZPTzNpQVlQTmd3RmlrSTFzbW5mTnZxZHh6VkR2aGF2bC9JWGE5S2cybmlXeXFLM0Q5enB6MFlENm1EdmwwWHNPZ1ROVnlHWE5WUkVkV2d6R0VFUkNRb3lISTF4b3d0L3N3ZTNLQ1h3K2xmK1hQRi90MVBmSHYwRGlEVms3MEFlV0dwUFBQdTZ5Z2dBSXhCNEF6NlBFWmhhUVd3ZVRDMGFuNDhsMkZIajJNdEIyUGlNSHRXMnY3Uk11RThBbDNQdEU0Z09BOENNRnJCK05weTZyS2NGQ1hPZ1RabTVicDdxK0oxcWtoQkRiaUJZdHZkc1l1ako1MlhhNVNpZlRwRWhHZVdXTEZuTExnUEFROG82YlhjV095Q29ZZkxmcDRKcGZ0L1k3SDhxekhiUGV3TlN5RDZtYUV2K3hsampmVTdoeGliYnN6ejVBNEpqTWRReTJCREdvVG1KeDdNYXMrZzZsNlp1SExWYmRtZ1FPdkQzd2FKQnk2ck9nMGV1dXgwQ240YkI0YklGRUYyS3ZiaGRHYlkxVWlxOURZYTdrRW1TRW5sY0FZYUh5cm9Ua0RnNGV3N0VSMHZJQkJNektNM3IrVWRQVktLUzY2dXlYdFpjPSIsCiAgICAgICAgIm5vbmNlIjogIlcrbHhRSmVHcTdYQUppR2ZjRG9oa2c9PSIKICAgIH0sCiAgICAidXNlcnMiOiB7CiAgICAgICAgImNlcnRpZmljYXRlIjogIi0tLS0tQkVHSU4gQ0VSVElGSUNBVEUtLS0tLVxuTUlJRGtEQ0NBbmlnQXdJQkFnSUJBREFOQmdrcWhraUc5dzBCQVFVRkFEQmhNUXN3Q1FZRFZRUUdFd0pFUlRFYlxuTUJrR0ExVUVDQXdTUW1Ga1pXNHRWM1ZsY25SMFpXMWlaWEpuTVJJd0VBWURWUVFIREFsVGRIVjBkR2RoY25ReFxuRWpBUUJnTlZCQW9NQ1U1bGVIUmpiRzkxWkRFTk1Bc0dBMVVFQXd3RWFtOW9iakFlRncweU16QTNNVFF3TnpNMFxuTlRaYUZ3MDBNekEzTURrd056TTBOVFphTUdFeEN6QUpCZ05WQkFZVEFrUkZNUnN3R1FZRFZRUUlEQkpDWVdSbFxuYmkxWGRXVnlkSFJsYldKbGNtY3hFakFRQmdOVkJBY01DVk4wZFhSMFoyRnlkREVTTUJBR0ExVUVDZ3dKVG1WNFxuZEdOc2IzVmtNUTB3Q3dZRFZRUUREQVJxYjJodU1JSUJJakFOQmdrcWhraUc5dzBCQVFFRkFBT0NBUThBTUlJQlxuQ2dLQ0FRRUE3ajNFcjVZYWhKVDBMQW5TUkxocHFiUm8rRTFBVm50OThydnAzRG1FZkJITnpOQitEUzlJQkRrU1xuU1hNL1l0ZkFjaTZUY3c4dWpWQmpyWlgvV0VtcmY4eW5RSHhZbVNhSlNuUDh1QVQzMDYvTWNlWnBkcHJ1RWM5L1xuUzEwYTd2cDU0WmJsZDROWWRtZlM3MW9WRlZLZ003Yy9WdGh4K3JndTQ4ZnV4emJXQXZWWUxGY3g0N2h6MERKVFxubmp6MlphL1I2OHVYcHhmejdKOXVFWFlpcXNBcy9Gb2JEc0xabHVUM1J5eXdWUndLQmVkMUVaeFVlTElKaXl4cFxuVXRoaEdmSWI4YjNWZjlqWm9VVmkzbTVnbWM0c3BKUUh2WUFrZlpZSHpkOXJhczhqQnUxYWJRUnhjdTJDWW5Wb1xuNlkwbVR4aEtoUVMvbjVnanYzRXhpUUYzd3AvWFl3SURBUUFCbzFNd1VUQWRCZ05WSFE0RUZnUVVtVGVJTFZ1QlxudHY3MGZUR2tYV0dBdWVEcDVrQXdId1lEVlIwakJCZ3dGb0FVbVRlSUxWdUJ0djcwZlRHa1hXR0F1ZURwNWtBd1xuRHdZRFZSMFRBUUgvQkFVd0F3RUIvekFOQmdrcWhraUc5dzBCQVFVRkFBT0NBUUVBeVZ0cTlYQXZXN254U1cvOFxuaHAzMHo2eGJ6R2l1dmlYaHkvSm85MVZFYThJUnNXQ0NuM09tREZpVmR1VEVvd3g3NnRmOGNsSlAwZ2s3UG96aVxuNmRnLzdGaW4rRnFRR1hmQ2s4YkxBaDlnWEtBaWtRMkdLOHlSTjNzbFJGd1lDMm1tMjNIckxkS1haSFVxSmNwQlxuTXoyenNTck9HUGoxWXNZT2wvVThGVTZLQTdZajdVM3E3a0RNWVRBZ3pVUFpBSCtkMURJU0dXcFpzTWEwUllpZFxudmlnQ0NMQnlpY2NtUy9DbzRTYjFlc0Y1OEgrWXRWNStuRkJSd3g4ODFVMmcyVGdES0YxbFBNSy95M2Q4QjhtaFxuVXRXK2xGeFJwdnlOVURwc01qT0VyT3J0TkZFWWJnb1VKTHRxd0JNbXlHUitubW1oNnhuYTMzMVFXY1JBbXcwUFxubkRPNGV3PT1cbi0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS1cbiIsCiAgICAgICAgImVuY3J5cHRlZE1ldGFkYXRhS2V5IjogIkhWVDQ5Ylltd1hiR3MvZEoyYXZnVTl1bnJLblBmMDNNWVVJNVp5c1NSMUJ6NXBxejY0Z3pIMkdCQXVVSitRNFZtSHRFZmNNYVdXN1ZYZ3pmQ1F2NXhMQnJrK1JTZ2NMT0tubEl5YThqYURsZnR0V3hiZThqSksrLzArUVZQT2M2eWNBL3Q1SE5DUGcwOWh6aitnbmIyTDg5VUh4TDVhY2NaRDBpRXpiNWNRYkdyYy9ONkd0aGpnR3JnRkt0RmYwSGhEVnBsVXIrREw5YVR5S3VLTEJQanJqdVpidjhNNlpmWE85M21PTXdTWkgzYzNyd0RVSGIvS0VhVFIvT2c0cFdRbXJxcjFWeEdMcWVWLytHS1doek1ZVGhyT1pBVXorNWdzYmNrVTJNNVY5aStwaDB5Qkk1QmpPWlZoTnVEd1c4eVA4V3R5Ukp3UWMrVUJSZWkvUkdCUT09IiwKICAgICAgICAidXNlcklkIjogImpvaG4iCiAgICB9LAogICAgInZlcnNpb24iOiAiMiIKfQo=" -// -// // TOBI -// val metadata = -// """{"metadata":{"authenticationTag":"qDcJnAAGtGDlHWiQMBfXgw\u003d\u003d","ciphertext":"3zUhwIgJWMB7DvrbsDaMvh8MbJdoTxL0OMPCCdYSfBt7gB+V/hwqelL1IOaLto3avhHGSebnrotF06iEP/jZwWg9hApIPTHc8B4XTOY0/kezqYyVqTyquTUZpDpqgVAheQskZZ8I4Ir0seajUkt4KtVRfzO6v8CePRrEg6uKwdYsqDcJnAAGtGDlHWiQMBfXgw\u003d\u003d|4hbOyn1ykQL+9D6SnPY3cQ\u003d\u003d","nonce":"4hbOyn1ykQL+9D6SnPY3cQ\u003d\u003d"},"users":[{"certificate":"-----BEGIN CERTIFICATE-----\nMIIC6DCCAdCgAwIBAgIBADANBgkqhkiG9w0BAQUFADANMQswCQYDVQQDDAJ0MTAe\nFw0yMzA3MjUwNzU3MTJaFw00MzA3MjAwNzU3MTJaMA0xCzAJBgNVBAMMAnQxMIIB\nIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAtafHmDBcBqIu4HmMxMDW3j0S\ny+S0YaKwHnBRt85KSwcEov0B5FOLuLknoBGx4Dn3u93ilThXXxacPMHeXL7WPuAs\n21/G7vsqwvrRRnCduf+FUO/AZeDCNErzpsQ8LmTa4PUloLPUcImpSjrHwhMs9Ekv\nEbLRjbeSmSp9XvM+1fV/3jkT5jkOSnCFx5TGwGN5uHqwUir4UWXasvg253NK2XmW\nipKCDCR9TmH1baP3pNdoiChdmErT1c6E4DbBXpTw8XgP5ZbYH+qg1UQ/hC8nRJ3D\nyCcHL+dg/GYraBMhDn4w2Vvq77xNNoNWQ9cT5Ay6cJbQLBQoJQirygQFrobYRQID\nAQABo1MwUTAdBgNVHQ4EFgQUE9zCeA9/QMAtVgLxD23X6ZcodhMwHwYDVR0jBBgw\nFoAUE9zCeA9/QMAtVgLxD23X6ZcodhMwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG\n9w0BAQUFAAOCAQEAZdy/YjJlvnz3FQwxp6oVtMJccpdxveEPfLzgaverhtd/vP8O\nAvDzOLgQJHmrDS91SG503eU4cYGyuNKwd77OyTnqMg+GUEmJhGfPpSVrEIdh65jv\nq61T4oqBdehevVmBq54rGiwL0DGv1DlXQlwiJZP4qni2KnOEFcnvL3gVtRnQjXQ+\nkHvlMshkK6w021EMV5NfjG2zg67wC65rLaej5f6Ssp2S7g2VtmE4aXq1bjAuEbqk\n4TiyZHLDdsJuqzyGyyOpMV7i9ucXDoaZt9cGS9hT2vRxTrSH63vKR8Xeig9+stLw\nt9ONcUqCKP7hd8rajtxM4JIIRExwD8OkgARWGg\u003d\u003d\n-----END CERTIFICATE-----\n","encryptedMetadataKey":"s4kDkkLpk1mSmXedP7huiCNC4DYmDAmA2VYGem5M8jIGPC6miVQoo4WXZrEBhdsLw7Msf5iT3A3fTaHhwsI8Jf4McsFyM9/FXT1mCEaGOEpNjbKOlJY1uPUFNOhLqUfFiBos6oBT53hWwoXWjytYvLBbXuXY5YLOysjgBh6URrgFUZAJAmcOJ6OFKgfIIthoqkQc7CQUY97VsRzAXzeYTANBc2yW1pSN51HqftvMzvewFRsJQLcu7a9NjpTdG9LiLhn5eLXOLymXEE/aaPHKXeprlXLzrdWU1xwZRJqV+to2FEiH6CQNsO4+9h5m0VjXekiNeAFrsXB5cJgUipGuzQ\u003d\u003d","userId":"t1"}],"version":"2.0"}""" -// -// val base = EncryptionUtils.encodeStringToBase64String(metadata) -// -// val signature = -// "MIAGCSqGSIb3DQEHAqCAMIACAQExDTALBglghkgBZQMEAgEwCwYJKoZIhvcNAQcBoIAwggLoMIIB0KADAgECAgEAMA0GCSqGSIb3DQEBBQUAMA0xCzAJBgNVBAMMAnQxMB4XDTIzMDcyNTA3NTcxMloXDTQzMDcyMDA3NTcxMlowDTELMAkGA1UEAwwCdDEwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC1p8eYMFwGoi7geYzEwNbePRLL5LRhorAecFG3zkpLBwSi/QHkU4u4uSegEbHgOfe73eKVOFdfFpw8wd5cvtY+4CzbX8bu+yrC+tFGcJ25/4VQ78Bl4MI0SvOmxDwuZNrg9SWgs9RwialKOsfCEyz0SS8RstGNt5KZKn1e8z7V9X/eORPmOQ5KcIXHlMbAY3m4erBSKvhRZdqy+Dbnc0rZeZaKkoIMJH1OYfVto/ek12iIKF2YStPVzoTgNsFelPDxeA/lltgf6qDVRD+ELydEncPIJwcv52D8ZitoEyEOfjDZW+rvvE02g1ZD1xPkDLpwltAsFCglCKvKBAWuhthFAgMBAAGjUzBRMB0GA1UdDgQWBBQT3MJ4D39AwC1WAvEPbdfplyh2EzAfBgNVHSMEGDAWgBQT3MJ4D39AwC1WAvEPbdfplyh2EzAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBBQUAA4IBAQBl3L9iMmW+fPcVDDGnqhW0wlxyl3G94Q98vOBq96uG13+8/w4C8PM4uBAkeasNL3VIbnTd5ThxgbK40rB3vs7JOeoyD4ZQSYmEZ8+lJWsQh2HrmO+rrVPiioF16F69WYGrnisaLAvQMa/UOVdCXCIlk/iqeLYqc4QVye8veBW1GdCNdD6Qe+UyyGQrrDTbUQxXk1+MbbODrvALrmstp6Pl/pKynZLuDZW2YThperVuMC4RuqThOLJkcsN2wm6rPIbLI6kxXuL25xcOhpm31wZL2FPa9HFOtIfre8pHxd6KD36y0vC3041xSoIo/uF3ytqO3EzgkghETHAPw6SABFYaAAAxggHUMIIB0AIBATASMA0xCzAJBgNVBAMMAnQxAgEAMAsGCWCGSAFlAwQCAaCBljAYBgkqhkiG9w0BCQMxCwYJKoZIhvcNAQcBMBwGCSqGSIb3DQEJBTEPFw0yMzA3MjgwNzMwMTJaMCsGCSqGSIb3DQEJNDEeMBwwCwYJYIZIAWUDBAIBoQ0GCSqGSIb3DQEBCwUAMC8GCSqGSIb3DQEJBDEiBCAx7RTJg7hbY5Mkzjw3f6qhX7k/J0FdVz2cL3ow0AmyYjANBgkqhkiG9w0BAQsFAASCAQAbUmb9e7eoIcPNzDSmnzbrueBzgT8YszNGEI+1YCq8XdWN4kDztvP1ZNV21VCO6BvcbfUAnXXgcX5BPeLZNsgXPj3c8TbD59GQl3oT/tIchgMsA20RdAtIwvItlZKh+X6sp0OHkRPYSk/mEYKCKPqrKdJicRWex8ItCwpDR91KSOiKJrN/+DKOGG0sVI9gjzbtrHsN8HmVKxOoNV+wwipcLsWsEmuV+wvPCQ9HJidLX9Q17Bgfc+qJg19aB6iKLWPhjgnfpKGbK5VJuQTdDWPUJ2O4G3W/iwxJ0hAJ7tks4zIATmgGzhgTWYx5LVXbKcuL04xhIOjqwedHeCSBZSSaAAAAAAAA" -// -// val metadataFile = EncryptionUtils.deserializeJSON( -// metadata, -// object : TypeToken() {} -// ) -// assertNotNull(metadataFile) -// -// val certJohnString = metadataFile.users[0].certificate -// val certJohn = EncryptionUtils.convertCertFromString(certJohnString) -// -// val t1String = """-----BEGIN CERTIFICATE----- -// MIIC6DCCAdCgAwIBAgIBADANBgkqhkiG9w0BAQUFADANMQswCQYDVQQDDAJ0MTAe -// Fw0yMzA3MjUwNzU3MTJaFw00MzA3MjAwNzU3MTJaMA0xCzAJBgNVBAMMAnQxMIIB -// IjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAtafHmDBcBqIu4HmMxMDW3j0S -// y+S0YaKwHnBRt85KSwcEov0B5FOLuLknoBGx4Dn3u93ilThXXxacPMHeXL7WPuAs -// 21/G7vsqwvrRRnCduf+FUO/AZeDCNErzpsQ8LmTa4PUloLPUcImpSjrHwhMs9Ekv -// EbLRjbeSmSp9XvM+1fV/3jkT5jkOSnCFx5TGwGN5uHqwUir4UWXasvg253NK2XmW -// ipKCDCR9TmH1baP3pNdoiChdmErT1c6E4DbBXpTw8XgP5ZbYH+qg1UQ/hC8nRJ3D -// yCcHL+dg/GYraBMhDn4w2Vvq77xNNoNWQ9cT5Ay6cJbQLBQoJQirygQFrobYRQID -// AQABo1MwUTAdBgNVHQ4EFgQUE9zCeA9/QMAtVgLxD23X6ZcodhMwHwYDVR0jBBgw -// FoAUE9zCeA9/QMAtVgLxD23X6ZcodhMwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG -// 9w0BAQUFAAOCAQEAZdy/YjJlvnz3FQwxp6oVtMJccpdxveEPfLzgaverhtd/vP8O -// AvDzOLgQJHmrDS91SG503eU4cYGyuNKwd77OyTnqMg+GUEmJhGfPpSVrEIdh65jv -// q61T4oqBdehevVmBq54rGiwL0DGv1DlXQlwiJZP4qni2KnOEFcnvL3gVtRnQjXQ+ -// kHvlMshkK6w021EMV5NfjG2zg67wC65rLaej5f6Ssp2S7g2VtmE4aXq1bjAuEbqk -// 4TiyZHLDdsJuqzyGyyOpMV7i9ucXDoaZt9cGS9hT2vRxTrSH63vKR8Xeig9+stLw -// t9ONcUqCKP7hd8rajtxM4JIIRExwD8OkgARWGg== -// -----END CERTIFICATE-----""" -// -// val t1cert = EncryptionUtils.convertCertFromString(t1String) -// val t1PrivateKeyKey = EncryptionUtils.PEMtoPrivateKey(encryptionTestUtils.t1PrivateKey) -// -// // val signed = encryptionUtilsV2.getMessageSignature( -// // t1cert, -// // t1PrivateKeyKey, -// // metadataFile -// // ) -// -// assertTrue(encryptionUtilsV2.verifySignedMessage(signature1, metadata1, listOf(certJohn, t1cert))) -// } + @Test + fun verifyMetadataEmptyFolder() { + val folder = OCFile("/e/") + val enc1 = MockUser("enc1", "Nextcloud") + + val metadataKey = EncryptionUtils.generateKey() + val metadata = DecryptedMetadata( + mutableListOf(), + false, + 0, + mutableMapOf(), + mutableMapOf(), + metadataKey + ) + // checksum must be present before encrypting, otherwise verifyMetadata returns false + metadata.keyChecksums.add(encryptionUtilsV2.hashMetadataKey(metadataKey)) + + val metadataFile = DecryptedFolderMetadataFile( + metadata, + mutableListOf(DecryptedUser(enc1.accountName, enc1Cert, null)), + mutableMapOf(), + E2EVersion.V2_1.value + ) + + val encrypted = encryptionUtilsV2.encryptFolderMetadataFile( + metadataFile, + enc1UserId, + folder, + storageManager, + client, + enc1PrivateKey, + user, + targetContext, + arbitraryDataProvider + ) + val signature = encryptionUtilsV2.getMessageSignature(enc1Cert, enc1PrivateKey, encrypted) + + val result = encryptionUtilsV2.verifyMetadata(encrypted, metadataFile, 0, signature) + + assertTrue(result) + assertEquals(0, metadataFile.metadata.files.size) + assertEquals(0, metadataFile.metadata.folders.size) + assertEquals(0, metadataFile.metadata.counter) + } + + @Test + fun verifyMetadataWithOneFile() { + val folder = OCFile("/e/") + val enc1 = MockUser("enc1", "Nextcloud") + + val metadata = DecryptedMetadata( + mutableListOf(), + false, + 1, + mutableMapOf(), + mutableMapOf( + Pair( + EncryptionUtils.generateUid(), + DecryptedFile( + "document.pdf", + "application/pdf", + "initializationVector", + "authenticationTag", + "key1" + ) + ) + ), + EncryptionUtils.generateKey() + ) + metadata.keyChecksums.add(encryptionUtilsV2.hashMetadataKey(metadata.metadataKey)) + + val metadataFile = DecryptedFolderMetadataFile( + metadata, + mutableListOf(DecryptedUser(enc1.accountName, enc1Cert, null)), + mutableMapOf(), + E2EVersion.V2_1.value + ) + + val encrypted = encryptionUtilsV2.encryptFolderMetadataFile( + metadataFile, + enc1UserId, + folder, + storageManager, + client, + enc1PrivateKey, + user, + targetContext, + arbitraryDataProvider + ) + + val signature = encryptionUtilsV2.getMessageSignature(enc1Cert, enc1PrivateKey, encrypted) + + val result = encryptionUtilsV2.verifyMetadata(encrypted, metadataFile, 0, signature) + + assertTrue(result) + assertEquals(1, metadataFile.metadata.files.size) + assertEquals("document.pdf", metadataFile.metadata.files.values.first().filename) + } + + @Test + fun verifyMetadataCounterTooOld() { + val folder = OCFile("/e/") + val enc1 = MockUser("enc1", "Nextcloud") + val metadataFile = generateDecryptedFolderMetadataFile(enc1, enc1Cert) + + val encrypted = encryptionUtilsV2.encryptFolderMetadataFile( + metadataFile, + enc1UserId, + folder, + storageManager, + client, + enc1PrivateKey, + user, + targetContext, + arbitraryDataProvider + ) + + val signature = encryptionUtilsV2.getMessageSignature(enc1Cert, enc1PrivateKey, encrypted) + + // counter in metadata is 1, passing oldCounter=2 should fail + val result = encryptionUtilsV2.verifyMetadata(encrypted, metadataFile, 2, signature) + + assertFalse(result) + } + + @Test + fun verifyMetadataInvalidSignature() { + val folder = OCFile("/e/") + val enc1 = MockUser("enc1", "Nextcloud") + val metadataFile = generateDecryptedFolderMetadataFile(enc1, enc1Cert) + + val encrypted = encryptionUtilsV2.encryptFolderMetadataFile( + metadataFile, + enc1UserId, + folder, + storageManager, + client, + enc1PrivateKey, + user, + targetContext, + arbitraryDataProvider + ) + + val signature = encryptionUtilsV2.getMessageSignature(enc1Cert, enc1PrivateKey, encrypted) + + val result = encryptionUtilsV2.verifyMetadata(encrypted, metadataFile, 0, signature) + + assertFalse(result) + } + + @Test + fun verifyMetadataChecksumMismatch() { + val folder = OCFile("/e/") + val enc1 = MockUser("enc1", "Nextcloud") + val metadataFile = generateDecryptedFolderMetadataFile(enc1, enc1Cert) + + val encrypted = encryptionUtilsV2.encryptFolderMetadataFile( + metadataFile, + enc1UserId, + folder, + storageManager, + client, + enc1PrivateKey, + user, + targetContext, + arbitraryDataProvider + ) + + // Replace the metadata key with a new one whose hash is NOT in keyChecksums + metadataFile.metadata.metadataKey = EncryptionUtils.generateKey() + + val signature = encryptionUtilsV2.getMessageSignature(enc1Cert, enc1PrivateKey, encrypted) + + val result = encryptionUtilsV2.verifyMetadata(encrypted, metadataFile, 0, signature) + + assertFalse(result) + } @Throws(Throwable::class) @Test diff --git a/app/src/main/java/com/nextcloud/client/database/dao/FileDao.kt b/app/src/main/java/com/nextcloud/client/database/dao/FileDao.kt index 164df5698362..ed5aab7755af 100644 --- a/app/src/main/java/com/nextcloud/client/database/dao/FileDao.kt +++ b/app/src/main/java/com/nextcloud/client/database/dao/FileDao.kt @@ -188,4 +188,7 @@ interface FileDao { @Query("DELETE FROM filelist WHERE file_owner = :fileOwner AND path = :remotePath") fun deleteFileByRemotePath(fileOwner: String, remotePath: String): Int + + @Query("UPDATE filelist SET is_read_only = :readOnly WHERE file_owner = :fileOwner AND path = :path") + fun setReadOnly(fileOwner: String, path: String, readOnly: Int): Int } diff --git a/app/src/main/java/com/nextcloud/client/database/entity/FileEntity.kt b/app/src/main/java/com/nextcloud/client/database/entity/FileEntity.kt index 175287ba738c..89ff2b616cb9 100644 --- a/app/src/main/java/com/nextcloud/client/database/entity/FileEntity.kt +++ b/app/src/main/java/com/nextcloud/client/database/entity/FileEntity.kt @@ -121,5 +121,7 @@ data class FileEntity( @ColumnInfo(name = ProviderTableMeta.FILE_INTERNAL_TWO_WAY_SYNC_RESULT) val internalTwoWaySyncResult: String?, @ColumnInfo(name = ProviderTableMeta.FILE_UPLOADED) - val uploaded: Long? + val uploaded: Long?, + @ColumnInfo(name = ProviderTableMeta.FILE_IS_READ_ONLY) + val isReadOnly: Int? ) diff --git a/app/src/main/java/com/nextcloud/client/di/AppModule.java b/app/src/main/java/com/nextcloud/client/di/AppModule.java index 481ef6719d28..6df3a013d9e4 100644 --- a/app/src/main/java/com/nextcloud/client/di/AppModule.java +++ b/app/src/main/java/com/nextcloud/client/di/AppModule.java @@ -38,17 +38,21 @@ import com.nextcloud.client.migrations.MigrationsManager; import com.nextcloud.client.migrations.MigrationsManagerImpl; import com.nextcloud.client.network.ClientFactory; +import com.nextcloud.client.network.ConnectivityService; import com.nextcloud.client.notifications.AppNotificationManager; import com.nextcloud.client.notifications.AppNotificationManagerImpl; import com.nextcloud.client.preferences.AppPreferences; import com.nextcloud.client.utils.Throttler; -import com.owncloud.android.providers.UsersAndGroupsSearchConfig; +import com.nextcloud.utils.e2ee.E2EEActionResolver; +import com.nextcloud.utils.e2ee.E2EEKeyInspector; +import com.nextcloud.utils.thumbnail.FolderThumbnailGenerator; import com.owncloud.android.authentication.PassCodeManager; import com.owncloud.android.datamodel.ArbitraryDataProvider; import com.owncloud.android.datamodel.ArbitraryDataProviderImpl; import com.owncloud.android.datamodel.FileDataStorageManager; import com.owncloud.android.datamodel.SyncedFolderProvider; import com.owncloud.android.datamodel.UploadsStorageManager; +import com.owncloud.android.providers.UsersAndGroupsSearchConfig; import com.owncloud.android.ui.activities.data.activities.ActivitiesRepository; import com.owncloud.android.ui.activities.data.activities.ActivitiesServiceApi; import com.owncloud.android.ui.activities.data.activities.ActivitiesServiceApiImpl; @@ -57,7 +61,6 @@ import com.owncloud.android.ui.activities.data.files.FilesServiceApiImpl; import com.owncloud.android.ui.activities.data.files.RemoteFilesRepository; import com.owncloud.android.ui.dialog.setupEncryption.CertificateValidator; -import com.nextcloud.utils.thumbnail.FolderThumbnailGenerator; import com.owncloud.android.utils.theme.ViewThemeUtils; import org.greenrobot.eventbus.EventBus; @@ -280,4 +283,23 @@ FolderThumbnailGenerator folderThumbnailGenerator( return new FolderThumbnailGenerator(appPreferences, viewThemeUtils, context, accountManager); } + @Provides + E2EEKeyInspector e2eeKeyInspector( + Context context, + FileDataStorageManager fileDataStorageManager, + CertificateValidator certificateValidator, + ArbitraryDataProvider arbitraryDataProvider, + UserAccountManager accountManager) { + return new E2EEKeyInspector(context, fileDataStorageManager, certificateValidator, arbitraryDataProvider, accountManager); + } + + @Provides + E2EEActionResolver e2eeActionResolver( + FileDataStorageManager fileDataStorageManager, + ArbitraryDataProvider arbitraryDataProvider, + UserAccountManager accountManager, + ConnectivityService connectivityService, + E2EEKeyInspector e2eeKeyInspector) { + return new E2EEActionResolver(fileDataStorageManager, arbitraryDataProvider, accountManager, connectivityService, e2eeKeyInspector); + } } diff --git a/app/src/main/java/com/nextcloud/client/jobs/offlineOperations/OfflineOperationsWorker.kt b/app/src/main/java/com/nextcloud/client/jobs/offlineOperations/OfflineOperationsWorker.kt index b53a4cc5dc04..8a1b966031ad 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/offlineOperations/OfflineOperationsWorker.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/offlineOperations/OfflineOperationsWorker.kt @@ -18,6 +18,7 @@ import com.nextcloud.client.network.ConnectivityService import com.nextcloud.model.OfflineOperationType import com.nextcloud.model.WorkerState import com.nextcloud.model.WorkerStateObserver +import com.nextcloud.utils.extensions.isNetworkAndServerAvailableSuspended import com.owncloud.android.datamodel.FileDataStorageManager import com.owncloud.android.datamodel.OCFile import com.owncloud.android.lib.common.OwnCloudClient @@ -35,8 +36,6 @@ import com.owncloud.android.utils.MimeTypeUtil import com.owncloud.android.utils.theme.ViewThemeUtils import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -import kotlin.coroutines.resume -import kotlin.coroutines.suspendCoroutine private typealias OfflineOperationResult = Pair?, RemoteOperation<*>?>? @@ -67,7 +66,7 @@ class OfflineOperationsWorker( Log_OC.d(TAG, "[$jobName] OfflineOperationsWorker started for user: ${user.accountName}") // check network connection - if (!isNetworkAndServerAvailable()) { + if (!connectivityService.isNetworkAndServerAvailableSuspended()) { Log_OC.w(TAG, "⚠️ No internet/server connection. Retrying later...") return@withContext Result.retry() } @@ -161,12 +160,6 @@ class OfflineOperationsWorker( } // endregion - private suspend fun isNetworkAndServerAvailable(): Boolean = suspendCoroutine { continuation -> - connectivityService.isNetworkAndServerAvailable { result -> - continuation.resume(result) - } - } - // region Operation Execution @Suppress("ComplexCondition", "LongMethod") private suspend fun executeOperation( diff --git a/app/src/main/java/com/nextcloud/utils/e2ee/E2EEActionResolver.kt b/app/src/main/java/com/nextcloud/utils/e2ee/E2EEActionResolver.kt new file mode 100644 index 000000000000..db221aaeb968 --- /dev/null +++ b/app/src/main/java/com/nextcloud/utils/e2ee/E2EEActionResolver.kt @@ -0,0 +1,90 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.utils.e2ee + +import com.nextcloud.client.account.UserAccountManager +import com.nextcloud.client.di.Injectable +import com.nextcloud.client.network.ConnectivityService +import com.nextcloud.utils.e2ee.model.E2EEKeyCheck +import com.nextcloud.utils.extensions.isNetworkAndServerAvailableSuspended +import com.owncloud.android.datamodel.ArbitraryDataProvider +import com.owncloud.android.datamodel.FileDataStorageManager +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.ui.dialog.setupEncryption.model.DownloadKeyResult +import com.owncloud.android.utils.EncryptionUtils +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import javax.inject.Inject + +@Suppress("LongParameterList") +class E2EEActionResolver @Inject constructor( + private val storageManager: FileDataStorageManager, + private val arbitraryDataProvider: ArbitraryDataProvider, + private val accountManager: UserAccountManager, + private val connectivityService: ConnectivityService, + private val inspector: E2EEKeyInspector +) : Injectable { + + companion object { + private const val TAG = "E2EEActionResolver" + } + + suspend fun markFolderReadOnly(file: OCFile) = withContext(Dispatchers.IO) { + storageManager.setReadOnly(file, true) + } + + suspend fun checkFolderMetadataKey(file: OCFile): Boolean = withContext(Dispatchers.IO) { + val capability = storageManager.getCapability(accountManager.user) + val canDecrypt = inspector.canDecryptFolderMetadata(file, capability) + storageManager.setReadOnly(file, !canDecrypt) + return@withContext canDecrypt + } + + suspend fun checkKeys(): E2EEKeyCheck = withContext(Dispatchers.IO) { resolveKeyCheck() } + + private suspend fun resolveKeyCheck(): E2EEKeyCheck { + val keysAbsentLocally = inspector.isLocalKeysAbsent() + + if (!inspector.fetchCapabilities()) { + return if (connectivityService.isNetworkAndServerAvailableSuspended()) { + E2EEKeyCheck.CHECK_FAILED + } else { + E2EEKeyCheck.NO_NETWORK + } + } + + val capability = storageManager.getCapability(accountManager.user) + val keysExistOnServer = capability.endToEndEncryptionKeysExist + + val result = when { + keysExistOnServer.isUnknown || capability.endToEndEncryption.isUnknown -> E2EEKeyCheck.E2EE_UNAVAILABLE + keysAbsentLocally && keysExistOnServer.isTrue -> E2EEKeyCheck.ONLY_ON_SERVER + keysAbsentLocally -> E2EEKeyCheck.MISSING_EVERYWHERE + !keysExistOnServer.isTrue -> E2EEKeyCheck.ONLY_ON_DEVICE + else -> compareKeys() + } + + Log_OC.d(TAG, "e2ee key check result: $result") + + return result + } + + private suspend fun compareKeys(): E2EEKeyCheck { + val storedPublicKey = arbitraryDataProvider.getValue(accountManager.user, EncryptionUtils.PUBLIC_KEY) + + return when (val result = inspector.compareWithServerKey(storedPublicKey)) { + is DownloadKeyResult.CompareKeys -> + if (result.same) E2EEKeyCheck.SAME_AS_SERVER else E2EEKeyCheck.DIFFERS_FROM_SERVER + + is DownloadKeyResult.NoServerKey -> E2EEKeyCheck.ONLY_ON_DEVICE + + else -> E2EEKeyCheck.CHECK_FAILED + } + } +} diff --git a/app/src/main/java/com/nextcloud/utils/e2ee/E2EEDialogPresenter.kt b/app/src/main/java/com/nextcloud/utils/e2ee/E2EEDialogPresenter.kt new file mode 100644 index 000000000000..6acb8e841d51 --- /dev/null +++ b/app/src/main/java/com/nextcloud/utils/e2ee/E2EEDialogPresenter.kt @@ -0,0 +1,50 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.utils.e2ee + +import com.nextcloud.utils.e2ee.model.E2EEDialog +import com.owncloud.android.R +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.ui.dialog.ConfirmationDialogFragment +import com.owncloud.android.ui.dialog.ConfirmationDialogFragment.ConfirmationDialogFragmentListener +import com.owncloud.android.ui.fragment.OCFileListFragment + +class E2EEDialogPresenter(private val fragment: OCFileListFragment) { + + companion object { + private const val TAG = "E2EEDialogPresenter" + private const val ENCRYPTION_KEY_ALERT_DIALOG_TAG = "ENCRYPTION_KEY_HANDLER_DIALOG" + private const val NO_BUTTON = -1 + } + + fun show(dialog: E2EEDialog) { + if (fragment.parentFragmentManager.findFragmentByTag(ENCRYPTION_KEY_ALERT_DIALOG_TAG) != null) { + return + } + + ConfirmationDialogFragment + .newInstance( + titleResId = dialog.titleId, + titleIconId = R.drawable.ic_lock_open_white, + messageResId = dialog.descriptionId, + positiveButtonTextId = R.string.common_ok, + neutralButtonTextId = NO_BUTTON, + negativeButtonTextId = NO_BUTTON, + messageArguments = null + ).apply { + setOnConfirmationListener(dismissListener(dialog)) + }.show(fragment.parentFragmentManager, ENCRYPTION_KEY_ALERT_DIALOG_TAG) + } + + private fun dismissListener(dialog: E2EEDialog): ConfirmationDialogFragmentListener = + object : ConfirmationDialogFragmentListener { + override fun onConfirmation(callerTag: String?) = Log_OC.d(TAG, "$dialog acknowledged") + override fun onNeutral(callerTag: String?) = Unit + override fun onCancel(callerTag: String?) = Unit + } +} diff --git a/app/src/main/java/com/nextcloud/utils/e2ee/E2EEKeyInspector.kt b/app/src/main/java/com/nextcloud/utils/e2ee/E2EEKeyInspector.kt new file mode 100644 index 000000000000..224fc8364b11 --- /dev/null +++ b/app/src/main/java/com/nextcloud/utils/e2ee/E2EEKeyInspector.kt @@ -0,0 +1,149 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.utils.e2ee + +import android.content.Context +import com.google.gson.reflect.TypeToken +import com.nextcloud.client.account.UserAccountManager +import com.owncloud.android.datamodel.ArbitraryDataProvider +import com.owncloud.android.datamodel.FileDataStorageManager +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.datamodel.e2e.v1.encrypted.EncryptedFolderMetadataFileV1 +import com.owncloud.android.datamodel.e2e.v2.encrypted.EncryptedFolderMetadataFile +import com.owncloud.android.lib.common.OwnCloudClientFactory +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.lib.resources.e2ee.GetMetadataRemoteOperation +import com.owncloud.android.lib.resources.status.OCCapability +import com.owncloud.android.lib.resources.users.GetPublicKeyRemoteOperation +import com.owncloud.android.lib.resources.users.GetServerPublicKeyRemoteOperation +import com.owncloud.android.operations.GetCapabilitiesOperation +import com.owncloud.android.ui.dialog.setupEncryption.CertificateValidator +import com.owncloud.android.ui.dialog.setupEncryption.model.DownloadKeyResult +import com.owncloud.android.utils.EncryptionUtils +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.apache.commons.httpclient.HttpStatus +import javax.inject.Inject + +@Suppress("TooGenericExceptionCaught", "ReturnCount") +class E2EEKeyInspector @Inject constructor( + private val context: Context, + private val storageManager: FileDataStorageManager, + private val certificateValidator: CertificateValidator, + private val arbitraryDataProvider: ArbitraryDataProvider, + private val accountManager: UserAccountManager +) { + companion object { + private const val TAG = "E2EEKeyInspector" + } + + fun isLocalKeysAbsent(): Boolean = + arbitraryDataProvider.getValue(accountManager.user, EncryptionUtils.PRIVATE_KEY).isEmpty() || + arbitraryDataProvider.getValue(accountManager.user, EncryptionUtils.PUBLIC_KEY).isEmpty() + + suspend fun fetchCapabilities(): Boolean = withContext(Dispatchers.IO) { + val result = GetCapabilitiesOperation(storageManager).execute(context).isSuccess + if (result) { + Log_OC.i(TAG, "capabilities fetched") + } else { + Log_OC.e(TAG, "error fetching capabilities") + } + return@withContext result + } + + suspend fun compareWithServerKey(storedPublicKey: String): DownloadKeyResult = withContext(Dispatchers.IO) { + Log_OC.d(TAG, "comparing local public key with remote public key") + + val user = accountManager.user + + val certificateResult = GetPublicKeyRemoteOperation().executeNextcloudClient(user, context) + if (!certificateResult.isSuccess) { + return@withContext if (certificateResult.httpCode == HttpStatus.SC_NOT_FOUND) { + DownloadKeyResult.NoServerKey + } else { + DownloadKeyResult.CertificateUnavailable() + } + } + + val serverPublicKeyResult = GetServerPublicKeyRemoteOperation().executeNextcloudClient(user, context) + if (!serverPublicKeyResult.isSuccess) return@withContext DownloadKeyResult.ServerPublicKeyUnavailable() + + val isCertificateValid = certificateValidator.validate( + serverPublicKeyResult.resultData, + certificateResult.resultData + ) + if (!isCertificateValid) return@withContext DownloadKeyResult.CertificateVerificationFailed() + + return@withContext DownloadKeyResult.CompareKeys(same = (storedPublicKey == certificateResult.resultData)) + } + + suspend fun canDecryptFolderMetadata(folder: OCFile, capability: OCCapability): Boolean = + withContext(Dispatchers.IO) { + Log_OC.d(TAG, "checking folder metadata key") + + val client = OwnCloudClientFactory.createOwnCloudClient(accountManager.currentAccount, context) + val metadataResult = GetMetadataRemoteOperation(folder.localId).execute(client) + + if (!metadataResult.isSuccess) { + return@withContext false + } + + val privateKey = arbitraryDataProvider.getValue(accountManager.user, EncryptionUtils.PRIVATE_KEY) + if (privateKey.isEmpty()) { + Log_OC.e(TAG, "user try to decrypt folder with empty private key") + return@withContext false + } + + val metadata = metadataResult.resultData + + return@withContext if (E2EVersionHelper.isV2Plus(capability)) { + decryptsMetadataV2(metadata.metadata, privateKey, client.userId) + } else { + decryptsMetadataV1(metadata.metadata, privateKey, folder.localId) + } + } + + private fun decryptsMetadataV2(serializedMetadata: String, privateKey: String, userId: String): Boolean { + val metadataFile = EncryptionUtils.deserializeJSON( + serializedMetadata, + object : TypeToken() {} + ) + + val user = metadataFile.users.find { it.userId == userId } + ?: throw IllegalStateException("cannot find current user in metadata") + + return try { + EncryptionUtils.decryptStringAsymmetricV2(user.encryptedMetadataKey, privateKey) + true + } catch (e: Exception) { + Log_OC.w(TAG, "user tried to decrypt folder's metadata with different private key: $e") + false + } + } + + private fun decryptsMetadataV1(serializedMetadata: String, privateKey: String, folderLocalId: Long): Boolean { + val metadataFile = EncryptionUtils.deserializeJSON( + serializedMetadata, + object : TypeToken() {} + ) + + return try { + EncryptionUtils.decryptFolderMetaData( + metadataFile, + privateKey, + arbitraryDataProvider, + accountManager.user, + folderLocalId + ) + true + } catch (e: Exception) { + Log_OC.w(TAG, "user tried to decrypt folder's metadata with different private key: $e") + false + } + } +} diff --git a/app/src/main/java/com/nextcloud/utils/e2ee/model/E2EEAction.kt b/app/src/main/java/com/nextcloud/utils/e2ee/model/E2EEAction.kt new file mode 100644 index 000000000000..d746c42db010 --- /dev/null +++ b/app/src/main/java/com/nextcloud/utils/e2ee/model/E2EEAction.kt @@ -0,0 +1,14 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.utils.e2ee.model + +enum class E2EEAction { + NEW_FOLDER, + OPEN, + ENCRYPT +} diff --git a/app/src/main/java/com/nextcloud/utils/e2ee/model/E2EEDialog.kt b/app/src/main/java/com/nextcloud/utils/e2ee/model/E2EEDialog.kt new file mode 100644 index 000000000000..489f30d94407 --- /dev/null +++ b/app/src/main/java/com/nextcloud/utils/e2ee/model/E2EEDialog.kt @@ -0,0 +1,33 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.utils.e2ee.model + +import androidx.annotation.StringRes +import com.owncloud.android.R + +enum class E2EEDialog(@param:StringRes val titleId: Int, @param:StringRes val descriptionId: Int) { + KeysRevokedOrChangedWhenEncrypting( + titleId = R.string.encryption_key_handle_key_removed_title, + descriptionId = R.string.encryption_key_handle_setup_new_key_before_encrypting_description + ), + + KeysLocallyOutdated( + titleId = R.string.encryption_key_handle_key_removed_title, + descriptionId = R.string.encryption_key_handle_key_keys_only_on_device_outdated_description + ), + + KeysOnlyExistsOnDeviceWhenCreatingFolder( + titleId = R.string.encryption_key_handle_key_removed_title, + descriptionId = R.string.encryption_key_handle_key_keys_only_on_device_description + ), + + KeysUnusableWhenOpeningFolder( + titleId = R.string.encryption_key_handle_key_removed_title, + descriptionId = R.string.encryption_key_handle_folder_read_only_description + ) +} diff --git a/app/src/main/java/com/nextcloud/utils/e2ee/model/E2EEKeyCheck.kt b/app/src/main/java/com/nextcloud/utils/e2ee/model/E2EEKeyCheck.kt new file mode 100644 index 000000000000..38764220c6ef --- /dev/null +++ b/app/src/main/java/com/nextcloud/utils/e2ee/model/E2EEKeyCheck.kt @@ -0,0 +1,111 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.utils.e2ee.model + +import com.owncloud.android.R + +enum class E2EEKeyCheck { + NO_NETWORK, + CHECK_FAILED, + E2EE_UNAVAILABLE, + ONLY_ON_SERVER, + MISSING_EVERYWHERE, + ONLY_ON_DEVICE, + SAME_AS_SERVER, + DIFFERS_FROM_SERVER; + + fun getDialog(action: E2EEAction): E2EEDialog? = when (this) { + ONLY_ON_DEVICE -> { + when (action) { + E2EEAction.OPEN -> { + E2EEDialog.KeysUnusableWhenOpeningFolder + } + + E2EEAction.ENCRYPT -> { + E2EEDialog.KeysRevokedOrChangedWhenEncrypting + } + + else -> { + E2EEDialog.KeysOnlyExistsOnDeviceWhenCreatingFolder + } + } + } + + DIFFERS_FROM_SERVER -> { + when (action) { + E2EEAction.OPEN -> { + E2EEDialog.KeysUnusableWhenOpeningFolder + } + + E2EEAction.ENCRYPT -> { + E2EEDialog.KeysRevokedOrChangedWhenEncrypting + } + + else -> { + E2EEDialog.KeysLocallyOutdated + } + } + } + + else -> null + } + + fun getMessageId(action: E2EEAction): Int? = when (this) { + NO_NETWORK -> when (action) { + E2EEAction.NEW_FOLDER -> { + R.string.encryption_new_folder_no_network + } + + E2EEAction.OPEN -> { + R.string.encryption_open_no_network + } + + else -> { + R.string.encryption_encrypt_no_network + } + } + + CHECK_FAILED -> when (action) { + E2EEAction.NEW_FOLDER -> { + R.string.encryption_new_folder_check_failed + } + + E2EEAction.OPEN -> { + R.string.encryption_open_check_failed + } + + else -> { + R.string.encryption_encrypt_check_failed + } + } + + E2EE_UNAVAILABLE -> when (action) { + E2EEAction.NEW_FOLDER -> { + R.string.encryption_new_folder_unavailable + } + + E2EEAction.OPEN -> { + R.string.encryption_open_unavailable + } + + else -> { + R.string.encryption_encrypt_unavailable + } + } + + MISSING_EVERYWHERE -> { + if (action == E2EEAction.OPEN) { + R.string.encryption_key_handle_no_keys_description + } else { + null + } + } + + else -> null + } +} diff --git a/app/src/main/java/com/nextcloud/utils/extensions/ConnectivityServiceExtensions.kt b/app/src/main/java/com/nextcloud/utils/extensions/ConnectivityServiceExtensions.kt new file mode 100644 index 000000000000..7038e6672a3d --- /dev/null +++ b/app/src/main/java/com/nextcloud/utils/extensions/ConnectivityServiceExtensions.kt @@ -0,0 +1,21 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.utils.extensions + +import com.nextcloud.client.network.ConnectivityService +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlin.coroutines.resume + +suspend fun ConnectivityService.isNetworkAndServerAvailableSuspended(): Boolean = + suspendCancellableCoroutine { continuation -> + isNetworkAndServerAvailable { available -> + if (continuation.isActive) { + continuation.resume(available) + } + } + } diff --git a/app/src/main/java/com/nextcloud/utils/extensions/OCFileExtensions.kt b/app/src/main/java/com/nextcloud/utils/extensions/OCFileExtensions.kt index b8db4fa83bc4..ae8b84b1efdc 100644 --- a/app/src/main/java/com/nextcloud/utils/extensions/OCFileExtensions.kt +++ b/app/src/main/java/com/nextcloud/utils/extensions/OCFileExtensions.kt @@ -15,6 +15,7 @@ import com.owncloud.android.datamodel.OCFileDepth import com.owncloud.android.datamodel.OCFileDepth.DeepLevel import com.owncloud.android.datamodel.OCFileDepth.FirstLevel import com.owncloud.android.datamodel.OCFileDepth.Root +import com.owncloud.android.ui.events.EncryptionEvent import com.owncloud.android.utils.FileStorageUtils import java.util.Calendar import java.util.Date @@ -82,3 +83,10 @@ private fun transformToRows(list: List, columns: Int, defaultSize: Int): .chunked(columns) .map { chunk -> GalleryRow(chunk, defaultSize, defaultSize) } } + +fun OCFile.toEncryptionEvent(encrypt: Boolean): EncryptionEvent = EncryptionEvent( + localId, + remoteId, + remotePath, + encrypt +) diff --git a/app/src/main/java/com/nextcloud/utils/extensions/OCFileListFragmentExtensions.kt b/app/src/main/java/com/nextcloud/utils/extensions/OCFileListFragmentExtensions.kt new file mode 100644 index 000000000000..89519699de9e --- /dev/null +++ b/app/src/main/java/com/nextcloud/utils/extensions/OCFileListFragmentExtensions.kt @@ -0,0 +1,84 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.utils.extensions + +import android.os.Bundle +import androidx.lifecycle.lifecycleScope +import com.owncloud.android.R +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.ui.activity.FileActivity +import com.owncloud.android.ui.dialog.setupEncryption.SetupEncryptionDialogFragment +import com.owncloud.android.ui.dialog.setupEncryption.SetupEncryptionDialogFragment.Companion.ARG_FILE_PATH +import com.owncloud.android.ui.dialog.setupEncryption.SetupEncryptionDialogFragment.Companion.RESULT_REQUEST_KEY +import com.owncloud.android.ui.dialog.setupEncryption.SetupEncryptionDialogFragment.Companion.SUCCESS +import com.owncloud.android.ui.fragment.OCFileListFragment +import com.owncloud.android.utils.DisplayUtils +import kotlinx.coroutines.launch + +private const val TAG = "OCFileListFragmentExtensions" + +fun OCFileListFragment.showEncryptionDialog(remotePath: String?) { + if (parentFragmentManager.findFragmentByTag(SetupEncryptionDialogFragment.SETUP_ENCRYPTION_DIALOG_TAG) != null) { + return + } + + val user = accountManager.user + val connectivityService = typedActivity()?.connectivityService + connectivityService?.isNetworkAndServerAvailable { result -> + if (result == true) { + SetupEncryptionDialogFragment.newInstance(user, remotePath) + .show(parentFragmentManager, SetupEncryptionDialogFragment.SETUP_ENCRYPTION_DIALOG_TAG) + return@isNetworkAndServerAvailable + } + + DisplayUtils.showSnackMessage( + this, + R.string.internet_connection_required_for_encrypted_folder_setup + ) + } +} + +fun OCFileListFragment.listenEncryptionDialogResult() { + parentFragmentManager.setFragmentResultListener( + RESULT_REQUEST_KEY, + this + ) { _: String?, bundle: Bundle -> + val result = bundle.getBoolean(SUCCESS, false) + if (!result) { + Log_OC.d(TAG, "setup encryption dialog is dismissed") + return@setFragmentResultListener + } + + val fileRemotePath = bundle.getString(ARG_FILE_PATH, null) + if (fileRemotePath == null) { + Log_OC.e(TAG, "file path is null") + return@setFragmentResultListener + } + + val file: OCFile? = mContainerActivity.getStorageManager().getFileByDecryptedRemotePath(fileRemotePath) + if (file == null) { + Log_OC.e(TAG, "file is null, cannot toggle encryption") + return@setFragmentResultListener + } + + if (file.isRootDirectory) { + Log_OC.d( + TAG, + "result of setup encryption triggered in root directory, this call is for " + + "creating encrypted folder" + ) + createFolder(true) + return@setFragmentResultListener + } + + lifecycleScope.launch { + folderEncryption.toggle(file.toEncryptionEvent(true)) + } + } +} diff --git a/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java b/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java index 15422af9d97e..3bbd25b3ffa9 100644 --- a/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java +++ b/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java @@ -839,6 +839,7 @@ private ContentValues createContentValuesBase(OCFile fileOrFolder) { cv.put(ProviderTableMeta.FILE_PATH_DECRYPTED, fileOrFolder.getDecryptedRemotePath()); cv.put(ProviderTableMeta.FILE_ACCOUNT_OWNER, user.getAccountName()); cv.put(ProviderTableMeta.FILE_IS_ENCRYPTED, fileOrFolder.isEncrypted()); + cv.put(ProviderTableMeta.FILE_IS_READ_ONLY, fileOrFolder.isReadOnly()); cv.put(ProviderTableMeta.FILE_LAST_SYNC_DATE, fileOrFolder.getLastSyncDateForProperties()); cv.put(ProviderTableMeta.FILE_LAST_SYNC_DATE_FOR_DATA, fileOrFolder.getLastSyncDateForData()); cv.put(ProviderTableMeta.FILE_ETAG, fileOrFolder.getEtag()); @@ -1396,6 +1397,7 @@ public OCFile createFileInstance(FileEntity fileEntity) { ocFile.setEtagInConflict(fileEntity.getEtagInConflict()); ocFile.setFavorite(nullToZero(fileEntity.getFavorite()) == 1); ocFile.setEncrypted(nullToZero(fileEntity.isEncrypted()) == 1); + ocFile.setReadOnly(nullToZero(fileEntity.isReadOnly()) == 1); // if (ocFile.isEncrypted()) { // ocFile.setFileName(cursor.getString(cursor.getColumnIndexOrThrow(ProviderTableMeta.FILE_NAME))); // } @@ -2837,6 +2839,34 @@ public void removeLocalFiles(User user, FileDataStorageManager storageManager) { FileStorageUtils.deleteRecursively(saveDir, storageManager); } + public void setReadOnly(OCFile file, boolean readOnly) { + OCFile root = getEncryptedRootFolder(file); + if (root != null) { + fileDao.setReadOnly(user.getAccountName(), root.getRemotePath(), readOnly ? 1 : 0); + } + } + + public boolean isReadOnly(OCFile file) { + OCFile root = getEncryptedRootFolder(file); + return root != null && root.isReadOnly(); + } + + @Nullable + private OCFile getEncryptedRootFolder(OCFile file) { + if (file == null || !file.isEncrypted()) { + return null; + } + + OCFile root = null; + OCFile current = getFileById(file.getFileId()); + while (current != null && current.isEncrypted()) { + root = current; + current = getFileById(current.getParentId()); + } + + return root; + } + public List getAllFiles() { // TODO - Apparently this method is used only by tests List fileEntities = fileDao.getAllFiles(user.getAccountName()); diff --git a/app/src/main/java/com/owncloud/android/datamodel/OCFile.java b/app/src/main/java/com/owncloud/android/datamodel/OCFile.java index eac2dc488c9f..5626e13771fa 100644 --- a/app/src/main/java/com/owncloud/android/datamodel/OCFile.java +++ b/app/src/main/java/com/owncloud/android/datamodel/OCFile.java @@ -100,6 +100,7 @@ public class OCFile implements Parcelable, Comparable, ServerFileInterfa private boolean favorite; private boolean hidden; private boolean encrypted; + private boolean readOnly; private WebdavEntry.MountType mountType; private int unreadCommentsCount; private String ownerId; @@ -197,6 +198,7 @@ private OCFile(Parcel source) { favorite = source.readInt() == 1; hidden = source.readInt() == 1; encrypted = source.readInt() == 1; + readOnly = source.readInt() == 1; ownerId = source.readString(); ownerDisplayName = source.readString(); mountType = (WebdavEntry.MountType) source.readSerializable(); @@ -243,6 +245,7 @@ public void writeToParcel(Parcel dest, int flags) { dest.writeInt(favorite ? 1 : 0); dest.writeInt(hidden ? 1 : 0); dest.writeInt(encrypted ? 1 : 0); + dest.writeInt(readOnly ? 1 : 0); dest.writeString(ownerId); dest.writeString(ownerDisplayName); dest.writeSerializable(mountType); @@ -517,6 +520,7 @@ private void resetData() { favorite = false; hidden = false; encrypted = false; + readOnly = false; mountType = WebdavEntry.MountType.INTERNAL; richWorkspace = ""; firstShareTimestamp = 0; @@ -851,6 +855,10 @@ public boolean isEncrypted() { return this.encrypted; } + public boolean isReadOnly() { + return this.readOnly; + } + public WebdavEntry.MountType getMountType() { return this.mountType; } @@ -967,6 +975,10 @@ public void setEncrypted(boolean encrypted) { this.encrypted = encrypted; } + public void setReadOnly(boolean readOnly) { + this.readOnly = readOnly; + } + public void setMountType(WebdavEntry.MountType mountType) { this.mountType = mountType; } diff --git a/app/src/main/java/com/owncloud/android/db/ProviderMeta.java b/app/src/main/java/com/owncloud/android/db/ProviderMeta.java index d966f43e4e23..bbc425488fc6 100644 --- a/app/src/main/java/com/owncloud/android/db/ProviderMeta.java +++ b/app/src/main/java/com/owncloud/android/db/ProviderMeta.java @@ -115,6 +115,7 @@ static public class ProviderTableMeta implements BaseColumns { public static final String FILE_FAVORITE = "favorite"; public static final String FILE_HIDDEN = "hidden"; public static final String FILE_IS_ENCRYPTED = "is_encrypted"; + public static final String FILE_IS_READ_ONLY = "is_read_only"; public static final String FILE_MOUNT_TYPE = "mount_type"; public static final String FILE_HAS_PREVIEW = "has_preview"; public static final String FILE_UNREAD_COMMENTS_COUNT = "unread_comments_count"; @@ -169,6 +170,7 @@ static public class ProviderTableMeta implements BaseColumns { FILE_FAVORITE, FILE_HIDDEN, FILE_IS_ENCRYPTED, + FILE_IS_READ_ONLY, FILE_MOUNT_TYPE, FILE_HAS_PREVIEW, FILE_UNREAD_COMMENTS_COUNT, diff --git a/app/src/main/java/com/owncloud/android/files/FileMenuFilter.java b/app/src/main/java/com/owncloud/android/files/FileMenuFilter.java index 47f103ead48f..0d7fa33d94a6 100644 --- a/app/src/main/java/com/owncloud/android/files/FileMenuFilter.java +++ b/app/src/main/java/com/owncloud/android/files/FileMenuFilter.java @@ -177,6 +177,7 @@ private List filter(boolean inSingleFileFragment) { filterPinToHome(toHide); filterRetry(toHide); filterPermissionActions(toHide); + filterReadOnly(toHide); return toHide; } @@ -385,6 +386,31 @@ private void filterSelectAll(List toHide, boolean inSingleFileFragment) } } + private void filterReadOnly(Collection toHide) { + boolean anyReadOnly = false; + for (OCFile file : files) { + if (storageManager.isReadOnly(file)) { + anyReadOnly = true; + break; + } + } + + if (!anyReadOnly) { + return; + } + + toHide.add(R.id.action_remove_file); + toHide.add(R.id.action_rename_file); + toHide.add(R.id.action_move_or_copy); + toHide.add(R.id.action_edit); + toHide.add(R.id.action_encrypted); + toHide.add(R.id.action_unset_encrypted); + toHide.add(R.id.action_lock_file); + toHide.add(R.id.action_unlock_file); + toHide.add(R.id.action_favorite); + toHide.add(R.id.action_unset_favorite); + } + private void filterRemove(List toHide, boolean synchronizing) { if (files.isEmpty() || synchronizing || containsLockedFile() || containsEncryptedFolder() || isFolderAndContainsEncryptedFile()) { diff --git a/app/src/main/java/com/owncloud/android/operations/RefreshFolderOperation.java b/app/src/main/java/com/owncloud/android/operations/RefreshFolderOperation.java index 9dc3cbf43f36..85af19137bc7 100644 --- a/app/src/main/java/com/owncloud/android/operations/RefreshFolderOperation.java +++ b/app/src/main/java/com/owncloud/android/operations/RefreshFolderOperation.java @@ -615,6 +615,7 @@ private void synchronizeData(List folderAndFiles) { // we parse content, so either the folder itself or its direct parent (which we check) must be encrypted boolean encrypted = updatedFile.isEncrypted() || mLocalFolder.isEncrypted(); updatedFile.setEncrypted(encrypted); + updatedFile.setReadOnly(localFile != null && localFile.isReadOnly()); updatedFiles.add(updatedFile); } diff --git a/app/src/main/java/com/owncloud/android/ui/activity/SetupEncryptionActivity.kt b/app/src/main/java/com/owncloud/android/ui/activity/SetupEncryptionActivity.kt index 65f438fa5c56..9ad74e9edf49 100644 --- a/app/src/main/java/com/owncloud/android/ui/activity/SetupEncryptionActivity.kt +++ b/app/src/main/java/com/owncloud/android/ui/activity/SetupEncryptionActivity.kt @@ -16,6 +16,9 @@ import com.owncloud.android.R import com.owncloud.android.ui.dialog.setupEncryption.SetupEncryptionDialogFragment import com.owncloud.android.utils.DisplayUtils +/** + * Only needed for SettingsActivity + */ class SetupEncryptionActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -45,16 +48,14 @@ class SetupEncryptionActivity : AppCompatActivity() { setupEncryptionDialogFragment.show(supportFragmentManager, "setup_encryption") } - private fun buildResultIntentFromBundle(result: Bundle): Intent { - val intent = Intent() - intent.putExtra( + private fun buildResultIntentFromBundle(result: Bundle): Intent = Intent().apply { + putExtra( SetupEncryptionDialogFragment.SUCCESS, result.getBoolean(SetupEncryptionDialogFragment.SUCCESS) ) - intent.putExtra( + putExtra( SetupEncryptionDialogFragment.ARG_FILE_PATH, result.getInt(SetupEncryptionDialogFragment.ARG_FILE_PATH) ) - return intent } } diff --git a/app/src/main/java/com/owncloud/android/ui/dialog/setupEncryption/model/DownloadKeyResult.kt b/app/src/main/java/com/owncloud/android/ui/dialog/setupEncryption/model/DownloadKeyResult.kt index 2d7c59341e3e..a57fd121608d 100644 --- a/app/src/main/java/com/owncloud/android/ui/dialog/setupEncryption/model/DownloadKeyResult.kt +++ b/app/src/main/java/com/owncloud/android/ui/dialog/setupEncryption/model/DownloadKeyResult.kt @@ -32,5 +32,9 @@ sealed class DownloadKeyResult(open val descriptionId: Int? = null) { data object GeneratePassphraseSendCSR : DownloadKeyResult() + data object NoServerKey : DownloadKeyResult() + + data class CompareKeys(val same: Boolean) : DownloadKeyResult() + data class Success(val privateKey: String) : DownloadKeyResult() } diff --git a/app/src/main/java/com/owncloud/android/ui/events/EncryptionEvent.kt b/app/src/main/java/com/owncloud/android/ui/events/EncryptionEvent.kt index 57bd97bf5caa..1a7dbc56c9a4 100644 --- a/app/src/main/java/com/owncloud/android/ui/events/EncryptionEvent.kt +++ b/app/src/main/java/com/owncloud/android/ui/events/EncryptionEvent.kt @@ -6,7 +6,37 @@ */ package com.owncloud.android.ui.events +import androidx.lifecycle.lifecycleScope +import com.nextcloud.utils.e2ee.model.E2EEAction +import com.nextcloud.utils.e2ee.model.E2EEKeyCheck +import com.nextcloud.utils.extensions.showEncryptionDialog +import com.owncloud.android.ui.fragment.OCFileListFragment +import com.owncloud.android.utils.DisplayUtils +import kotlinx.coroutines.launch + /** * Event for set folder as encrypted/decrypted */ -class EncryptionEvent(val localId: Long, val remoteId: String, val remotePath: String, val shouldBeEncrypted: Boolean) +class EncryptionEvent(val localId: Long, val remoteId: String, val remotePath: String, val shouldBeEncrypted: Boolean) { + fun onResult(fragment: OCFileListFragment) { + fragment.lifecycleScope.launch { + when (val state = fragment.e2eeActionResolver.checkKeys()) { + E2EEKeyCheck.NO_NETWORK, E2EEKeyCheck.E2EE_UNAVAILABLE, E2EEKeyCheck.CHECK_FAILED -> { + state.getMessageId(E2EEAction.ENCRYPT)?.let { DisplayUtils.showSnackMessage(fragment, it) } + } + + E2EEKeyCheck.ONLY_ON_SERVER, E2EEKeyCheck.MISSING_EVERYWHERE -> { + fragment.showEncryptionDialog(remotePath) + } + + E2EEKeyCheck.ONLY_ON_DEVICE, E2EEKeyCheck.DIFFERS_FROM_SERVER -> { + state.getDialog(E2EEAction.ENCRYPT)?.let { fragment.e2eeDialogPresenter.show(it) } + } + + E2EEKeyCheck.SAME_AS_SERVER -> { + fragment.folderEncryption.toggle(this@EncryptionEvent) + } + } + } + } +} diff --git a/app/src/main/java/com/owncloud/android/ui/fragment/EncryptedFolderClickHandler.kt b/app/src/main/java/com/owncloud/android/ui/fragment/EncryptedFolderClickHandler.kt new file mode 100644 index 000000000000..e182094edc46 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/fragment/EncryptedFolderClickHandler.kt @@ -0,0 +1,149 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.owncloud.android.ui.fragment + +import androidx.lifecycle.lifecycleScope +import com.google.android.material.snackbar.Snackbar +import com.nextcloud.utils.e2ee.model.E2EEAction +import com.nextcloud.utils.e2ee.model.E2EEKeyCheck +import com.nextcloud.utils.extensions.showEncryptionDialog +import com.owncloud.android.R +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.ui.activity.FileActivity +import com.owncloud.android.ui.activity.FolderPickerActivity +import com.owncloud.android.ui.helpers.FileOperationsHelper +import com.owncloud.android.utils.DisplayUtils +import kotlinx.coroutines.launch + +class EncryptedFolderClickHandler(private val fragment: OCFileListFragment) { + + companion object { + private const val TAG = "EncryptedFolderClickHandler" + } + + private var checkingKeysSnackbar: Snackbar? = null + + /** + * Executed when user taps the 'New encrypted folder' action. + */ + fun onNewEncryptedFolder() { + checkingKeysSnackbar = DisplayUtils.createAndShowSnackMessage( + fragment, + R.string.encryption_key_checking_keys + ) + + fragment.lifecycleScope.launch { + val state = fragment.e2eeActionResolver.checkKeys() + dismissCheckingSnackbar() + when (state) { + E2EEKeyCheck.NO_NETWORK, E2EEKeyCheck.CHECK_FAILED, E2EEKeyCheck.E2EE_UNAVAILABLE -> { + state.getMessageId(E2EEAction.NEW_FOLDER)?.let { DisplayUtils.showSnackMessage(fragment, it) } + } + + E2EEKeyCheck.ONLY_ON_SERVER, E2EEKeyCheck.MISSING_EVERYWHERE -> { + Log_OC.d(TAG, "keys found on server but missing locally, redirecting to encryption setup") + fragment.showEncryptionDialog(OCFile.ROOT_PATH) + } + + E2EEKeyCheck.SAME_AS_SERVER -> { + Log_OC.d(TAG, "keys valid, proceeding with new encrypted folder creation") + fragment.showCreateFolderDialog(true) + } + + E2EEKeyCheck.ONLY_ON_DEVICE, E2EEKeyCheck.DIFFERS_FROM_SERVER -> { + state.getDialog(E2EEAction.NEW_FOLDER)?.let { fragment.e2eeDialogPresenter.show(it) } + } + } + } + } + + fun onEncryptedFolderClick(file: OCFile, position: Int) { + val fileActivity = fragment.activity as? FileActivity ?: run { + Log_OC.e(TAG, "activity is not file activity, cannot handle encrypted folder") + return + } + + checkingKeysSnackbar = DisplayUtils.createAndShowSnackMessage( + fragment, + R.string.encryption_key_checking_keys + ) + + fragment.run { + lifecycleScope.launch { + val state = e2eeActionResolver.checkKeys() + + dismissCheckingSnackbar() + + when (state) { + E2EEKeyCheck.NO_NETWORK, E2EEKeyCheck.CHECK_FAILED, E2EEKeyCheck.E2EE_UNAVAILABLE -> { + state.getMessageId(E2EEAction.OPEN)?.let { DisplayUtils.showSnackMessage(fragment, it) } + } + + E2EEKeyCheck.ONLY_ON_SERVER -> { + fragment.showEncryptionDialog(file.remotePath) + } + + E2EEKeyCheck.MISSING_EVERYWHERE -> { + e2eeActionResolver.markFolderReadOnly(file) + state.getMessageId(E2EEAction.OPEN)?.let { DisplayUtils.showSnackMessage(fragment, it) } + } + + E2EEKeyCheck.ONLY_ON_DEVICE, E2EEKeyCheck.DIFFERS_FROM_SERVER -> { + e2eeActionResolver.markFolderReadOnly(file) + state.getDialog(E2EEAction.OPEN)?.let { e2eeDialogPresenter.show(it) } + } + + E2EEKeyCheck.SAME_AS_SERVER -> { + if (e2eeActionResolver.checkFolderMetadataKey(file)) { + onFolderKeyVerified(file, position, fileActivity) + } else { + DisplayUtils.showSnackMessage( + fragment, + R.string.encryption_open_key_mismatch + ) + } + } + } + } + } + } + + private fun dismissCheckingSnackbar() { + DisplayUtils.dismissSnackMessage(checkingKeysSnackbar) + checkingKeysSnackbar = null + } + + private fun onFolderKeyVerified(file: OCFile, position: Int, fileActivity: FileActivity) { + val user = fileActivity.user.orElseThrow { RuntimeException() } + val capability = fragment.mContainerActivity.getStorageManager().getCapability(user.accountName) + + if (capability.endToEndEncryption.isFalse || capability.endToEndEncryption.isUnknown) { + DisplayUtils.showSnackMessage(fragment, R.string.end_to_end_encryption_not_enabled) + return + } + + if (FileOperationsHelper.isEndToEndEncryptionSetup(fragment.context, user)) { + onEncryptionSetupComplete(file, position) + } else { + fragment.showEncryptionDialog(file.remotePath) + } + } + + private fun onEncryptionSetupComplete(file: OCFile?, position: Int) { + fragment.searchFragment = false + fragment.mHideFab = false + + val folderPickerActivity = fragment.mContainerActivity as? FolderPickerActivity + if (folderPickerActivity?.isDoNotEnterEncryptedFolder == true) { + DisplayUtils.showSnackMessage(fragment, R.string.copy_move_to_encrypted_folder_not_supported) + } else { + fragment.browseToFolder(file, position) + } + } +} diff --git a/app/src/main/java/com/owncloud/android/ui/fragment/FolderEncryption.kt b/app/src/main/java/com/owncloud/android/ui/fragment/FolderEncryption.kt new file mode 100644 index 000000000000..9823ec26effd --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/fragment/FolderEncryption.kt @@ -0,0 +1,162 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.owncloud.android.ui.fragment + +import com.nextcloud.client.account.User +import com.nextcloud.utils.e2ee.E2EVersionHelper +import com.owncloud.android.R +import com.owncloud.android.datamodel.FileDataStorageManager +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.lib.common.OwnCloudClient +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.lib.resources.e2ee.ToggleEncryptionRemoteOperation +import com.owncloud.android.lib.resources.status.E2EVersion +import com.owncloud.android.ui.events.EncryptionEvent +import com.owncloud.android.utils.DisplayUtils +import com.owncloud.android.utils.EncryptionUtils +import com.owncloud.android.utils.EncryptionUtilsV2 +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.apache.commons.httpclient.HttpStatus + +@Suppress("TooGenericExceptionCaught") +class FolderEncryption(private val fragment: OCFileListFragment) { + + companion object { + private const val TAG = "FolderEncryption" + } + + suspend fun toggle(event: EncryptionEvent): Boolean = withContext(Dispatchers.IO) { + val localId = event.localId + val remoteId = event.remoteId + val remotePath = event.remotePath + val shouldBeEncrypted = event.shouldBeEncrypted + + try { + val storageManager = fragment.mContainerActivity.storageManager + val folder = storageManager.getFileByRemoteId(remoteId) ?: run { + Log_OC.e(TAG, "folder is null, cannot encrypt") + return@withContext false + } + + val user = fragment.accountManager.user + val provider = fragment.arbitraryDataProvider + val publicKey = provider.getValue(user, EncryptionUtils.PUBLIC_KEY) + val privateKey = provider.getValue(user, EncryptionUtils.PRIVATE_KEY) + val client = fragment.clientFactory.create(user) + + val result = ToggleEncryptionRemoteOperation(localId, remotePath, shouldBeEncrypted) + .execute(client) + + return@withContext when { + result.isSuccess -> onToggleSuccess( + remoteId, + shouldBeEncrypted, + folder, + client, + user, + publicKey, + privateKey, + storageManager + ) + + result.httpCode == HttpStatus.SC_FORBIDDEN -> { + showSnackbar( + R.string.end_to_end_encryption_folder_not_empty + ) + false + } + + else -> { + showSnackbar(R.string.common_error_unknown) + false + } + } + } catch (t: Throwable) { + Log_OC.e(TAG, "error encrypting folder", t) + false + } + } + + @Suppress("LongParameterList") + private suspend fun onToggleSuccess( + remoteId: String, + shouldBeEncrypted: Boolean, + folder: OCFile, + client: OwnCloudClient, + user: User, + publicKey: String, + privateKey: String, + storageManager: FileDataStorageManager + ): Boolean { + val capability = storageManager.getCapability(user.accountName) + val isE2EEV2 = E2EVersionHelper.isV2Plus(capability) + var e2eCounter = EncryptionUtils.E2E_V1_INITIAL_COUNTER + if (isE2EEV2) { + e2eCounter = EncryptionUtils.E2E_V2_INITIAL_COUNTER + } + val token = EncryptionUtils.lockFolder(folder, client, e2eCounter) + + val result = when { + isE2EEV2 -> { + val result = EncryptionUtils.retrieveMetadata( + folder, + client, + privateKey, + publicKey, + storageManager, + user, + fragment.requireContext(), + fragment.arbitraryDataProvider + ) + val encryptionUtil = EncryptionUtilsV2() + encryptionUtil.serializeAndUploadMetadata( + folder, + result.second, + token, + client, + result.first, + fragment.requireContext(), + user, + storageManager + ) + EncryptionUtils.unlockFolder(folder, client, token) + true + } + + E2EVersionHelper.isV1(capability) -> { + EncryptionUtils.unlockFolderV1(folder, client, token) + false + } + + capability.endToEndEncryptionApiVersion == E2EVersion.UNKNOWN -> { + throw IllegalArgumentException("Unknown E2E version") + } + + else -> { + false + } + } + + withContext(Dispatchers.Main) { + val isFileExists = (fragment.adapter.getFileByRemoteId(remoteId) != null) + if (!isFileExists) { + val newFile = storageManager.getFileByRemoteId(remoteId) + fragment.adapter.insertFile(newFile) + } + + fragment.adapter.updateFileEncryptionById(remoteId, shouldBeEncrypted) + } + + return result + } + + private suspend fun showSnackbar(messageResId: Int) = withContext(Dispatchers.Main) { + DisplayUtils.showSnackMessage(fragment, messageResId) + } +} diff --git a/app/src/main/java/com/owncloud/android/ui/fragment/OCFileListFragment.java b/app/src/main/java/com/owncloud/android/ui/fragment/OCFileListFragment.java index 8a1c7335bdd7..f8d2549f7034 100644 --- a/app/src/main/java/com/owncloud/android/ui/fragment/OCFileListFragment.java +++ b/app/src/main/java/com/owncloud/android/ui/fragment/OCFileListFragment.java @@ -26,7 +26,6 @@ import android.os.Looper; import android.text.TextUtils; import android.util.Log; -import android.util.Pair; import android.view.ActionMode; import android.view.LayoutInflater; import android.view.Menu; @@ -56,12 +55,14 @@ import com.nextcloud.ui.fileactions.FileActionsBottomSheet; import com.nextcloud.utils.EditorUtils; import com.nextcloud.utils.ShortcutUtil; -import com.nextcloud.utils.e2ee.E2EVersionHelper; +import com.nextcloud.utils.e2ee.E2EEActionResolver; +import com.nextcloud.utils.e2ee.E2EEDialogPresenter; import com.nextcloud.utils.extensions.BundleExtensionsKt; import com.nextcloud.utils.extensions.FileExtensionsKt; import com.nextcloud.utils.extensions.FragmentExtensionsKt; import com.nextcloud.utils.extensions.IntentExtensionsKt; import com.nextcloud.utils.extensions.OCFileExtensionsKt; +import com.nextcloud.utils.extensions.OCFileListFragmentExtensionsKt; import com.nextcloud.utils.extensions.ViewExtensionsKt; import com.nextcloud.utils.fileNameValidator.FileNameValidator; import com.nextcloud.utils.thumbnail.ThumbnailGenerator; @@ -73,19 +74,13 @@ import com.owncloud.android.datamodel.OCFile; import com.owncloud.android.datamodel.OCFileDepth; import com.owncloud.android.datamodel.SyncedFolderProvider; -import com.owncloud.android.datamodel.e2e.v1.decrypted.DecryptedFolderMetadataFileV1; -import com.owncloud.android.datamodel.e2e.v1.decrypted.DecryptedMetadata; -import com.owncloud.android.datamodel.e2e.v1.encrypted.EncryptedFolderMetadataFileV1; -import com.owncloud.android.datamodel.e2e.v2.decrypted.DecryptedFolderMetadataFile; import com.owncloud.android.lib.common.Creator; import com.owncloud.android.lib.common.OwnCloudClient; import com.owncloud.android.lib.common.operations.RemoteOperation; import com.owncloud.android.lib.common.operations.RemoteOperationResult; import com.owncloud.android.lib.common.utils.Log_OC; -import com.owncloud.android.lib.resources.e2ee.ToggleEncryptionRemoteOperation; import com.owncloud.android.lib.resources.files.SearchRemoteOperation; import com.owncloud.android.lib.resources.files.ToggleFavoriteRemoteOperation; -import com.owncloud.android.lib.resources.status.E2EVersion; import com.owncloud.android.lib.resources.status.OCCapability; import com.owncloud.android.lib.resources.status.Type; import com.owncloud.android.ui.activity.AlbumsPickerActivity; @@ -105,7 +100,6 @@ import com.owncloud.android.ui.dialog.RemoveFilesDialogFragment; import com.owncloud.android.ui.dialog.RenameFileDialogFragment; import com.owncloud.android.ui.dialog.SyncFileNotEnoughSpaceDialogFragment; -import com.owncloud.android.ui.dialog.setupEncryption.SetupEncryptionDialogFragment; import com.owncloud.android.ui.events.ChangeMenuEvent; import com.owncloud.android.ui.events.CommentsEvent; import com.owncloud.android.ui.events.EncryptionEvent; @@ -118,8 +112,6 @@ import com.owncloud.android.ui.preview.PreviewImageFragment; import com.owncloud.android.ui.preview.PreviewMediaActivity; import com.owncloud.android.utils.DisplayUtils; -import com.owncloud.android.utils.EncryptionUtils; -import com.owncloud.android.utils.EncryptionUtilsV2; import com.owncloud.android.utils.FileSortOrder; import com.owncloud.android.utils.FileStorageUtils; import com.owncloud.android.utils.MimeTypeUtil; @@ -127,14 +119,12 @@ import com.owncloud.android.utils.WebViewUtil; import com.owncloud.android.utils.theme.ThemeUtils; -import org.apache.commons.httpclient.HttpStatus; import org.greenrobot.eventbus.EventBus; import org.greenrobot.eventbus.Subscribe; import org.greenrobot.eventbus.ThreadMode; import java.util.ArrayList; import java.util.Collection; -import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Objects; @@ -149,13 +139,11 @@ import androidx.core.content.ContextCompat; import androidx.drawerlayout.widget.DrawerLayout; import androidx.fragment.app.FragmentActivity; -import androidx.fragment.app.FragmentManager; import androidx.media3.common.util.UnstableApi; import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import kotlin.Unit; import static com.owncloud.android.datamodel.OCFile.ROOT_PATH; -import static com.owncloud.android.ui.dialog.setupEncryption.SetupEncryptionDialogFragment.SETUP_ENCRYPTION_DIALOG_TAG; import static com.owncloud.android.ui.fragment.SearchType.FAVORITE_SEARCH; import static com.owncloud.android.ui.fragment.SearchType.FILE_SEARCH; import static com.owncloud.android.ui.fragment.SearchType.GALLERY_SEARCH; @@ -214,8 +202,11 @@ public class OCFileListFragment extends ExtendedListFragment implements @Inject SyncedFolderProvider syncedFolderProvider; @Inject AppScanOptionalFeature appScanOptionalFeature; @Inject ThumbnailGenerator thumbnailGenerator; - - protected FileFragment.ContainerActivity mContainerActivity; + @Inject public E2EEActionResolver e2eeActionResolver; + public E2EEDialogPresenter e2eeDialogPresenter; + private EncryptedFolderClickHandler clickHandler; + public FolderEncryption folderEncryption; + public FileFragment.ContainerActivity mContainerActivity; protected OCFile mFile; protected OCFileListAdapter mAdapter; @@ -265,12 +256,15 @@ public void onCreate(Bundle savedInstanceState) { setSearchArgs(state); mFile = BundleExtensionsKt.getParcelableArgument(state, KEY_FILE, OCFile.class); searchFragment = currentSearchType != null && isSearchEventSet(searchEvent); + folderEncryption = new FolderEncryption(this); + clickHandler = new EncryptedFolderClickHandler(this); + e2eeDialogPresenter = new E2EEDialogPresenter(this); } @Override public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { super.onViewCreated(view, savedInstanceState); - listenSetupEncryptionDialogResult(); + OCFileListFragmentExtensionsKt.listenEncryptionDialogResult(this); } @Override @@ -428,7 +422,7 @@ public void prepareOCFileList(Bundle savedInstanceState) { setFabVisible(false); } else { if (mFile != null) { - setFabVisible(mFile.canCreateFileAndFolder()); + setFabVisible(canCreateInside(mFile)); } else { setFabVisible(true); } @@ -470,6 +464,19 @@ public void prepareOCFileList(Bundle savedInstanceState) { } } + private boolean isReadOnlyFolder(OCFile folder) { + if (folder == null || mContainerActivity == null) { + return false; + } + + final var storageManager = mContainerActivity.getStorageManager(); + return storageManager != null && storageManager.isReadOnly(folder); + } + + private boolean canCreateInside(OCFile folder) { + return folder.canCreateFileAndFolder() && !isReadOnlyFolder(folder); + } + protected void setAdapter(Bundle args) { boolean hideItemOptions = args != null && args.getBoolean(ARG_HIDE_ITEM_OPTIONS, false); boolean isGridViewPreferred = false; @@ -545,26 +552,19 @@ public void registerFabListener() { @Override public void createFolder(boolean encrypted) { - final var activity = getActivity(); - if (activity == null) { - Log_OC.e(TAG, "activity is null, cannot create a folder"); + if (encrypted) { + clickHandler.onNewEncryptedFolder(); return; } - if (encrypted) { - User user = accountManager.getUser(); - String publicKey = arbitraryDataProvider.getValue(user, EncryptionUtils.PUBLIC_KEY); - String privateKey = arbitraryDataProvider.getValue(user, EncryptionUtils.PRIVATE_KEY); - - if (publicKey.isEmpty() || privateKey.isEmpty()) { - Log_OC.w(TAG,"cannot create encrypted folder directly, needs to setup encryption first"); + showCreateFolderDialog(false); + } - activity.runOnUiThread(() -> { - final var dialog = SetupEncryptionDialogFragment.newInstance(user, mFile.getRemotePath()); - dialog.show(getParentFragmentManager(), SETUP_ENCRYPTION_DIALOG_TAG); - }); - return; - } + public void showCreateFolderDialog(boolean encrypted) { + final var activity = getActivity(); + if (activity == null) { + Log_OC.e(TAG, "activity is null, cannot create a folder"); + return; } CreateFolderDialogFragment.newInstance(mFile, encrypted) @@ -1140,56 +1140,7 @@ private void folderOnItemClick(OCFile file, int position) { } if (file.isEncrypted()) { - User user = ((FileActivity) mContainerActivity).getUser().orElseThrow(RuntimeException::new); - - // check if e2e app is enabled - OCCapability ocCapability = mContainerActivity.getStorageManager() - .getCapability(user.getAccountName()); - - if (ocCapability.getEndToEndEncryption().isFalse() || - ocCapability.getEndToEndEncryption().isUnknown()) { - - if (getRecyclerView() != null) { - Snackbar.make(getRecyclerView(), R.string.end_to_end_encryption_not_enabled, - Snackbar.LENGTH_LONG).show(); - } - - return; - } - // check if keys are stored - if (FileOperationsHelper.isEndToEndEncryptionSetup(requireContext(), user)) { - // update state and view of this fragment - searchFragment = false; - mHideFab = false; - - if (mContainerActivity instanceof FolderPickerActivity && - ((FolderPickerActivity) mContainerActivity) - .isDoNotEnterEncryptedFolder()) { - - if (getRecyclerView() != null) { - Snackbar.make(getRecyclerView(), - R.string.copy_move_to_encrypted_folder_not_supported, - Snackbar.LENGTH_LONG).show(); - } - } else { - browseToFolder(file, position); - } - } else { - Log_OC.d(TAG, "no public key for " + user.getAccountName()); - - FragmentManager fragmentManager = getParentFragmentManager(); - if (fragmentManager.findFragmentByTag(SETUP_ENCRYPTION_DIALOG_TAG) == null && requireActivity() instanceof FileActivity fileActivity) { - fileActivity.connectivityService.isNetworkAndServerAvailable(result -> { - if (result) { - SetupEncryptionDialogFragment dialog = SetupEncryptionDialogFragment.newInstance(user, file.getRemotePath()); - dialog.show(fragmentManager, SETUP_ENCRYPTION_DIALOG_TAG); - } else { - DisplayUtils.showSnackMessage(fileActivity, R.string.internet_connection_required_for_encrypted_folder_setup); - } - return Unit.INSTANCE; - }); - } - } + clickHandler.onEncryptedFolderClick(file, position); } else { // update state and view of this fragment searchFragment = false; @@ -1324,7 +1275,7 @@ public OCFileDepth getFileDepth() { return fileDepth; } - private void browseToFolder(OCFile file, int position) { + protected void browseToFolder(OCFile file, int position) { setFileDepth(file); if (currentSearchType == FAVORITE_SEARCH) { @@ -1338,52 +1289,6 @@ private void browseToFolder(OCFile file, int position) { saveIndexAndTopPosition(position); } - private void listenSetupEncryptionDialogResult() { - getParentFragmentManager().setFragmentResultListener( - SetupEncryptionDialogFragment.RESULT_REQUEST_KEY, - this, - (requestKey, bundle) -> { - boolean result = bundle.getBoolean(SetupEncryptionDialogFragment.SUCCESS, false); - if (!result) { - Log_OC.d(TAG, "setup encryption dialog is dismissed"); - boolean cancelled = bundle.getBoolean(SetupEncryptionDialogFragment.RESULT_KEY_CANCELLED, false); - if (cancelled) { - browseToRoot(); - } - return; - } - - String fileRemotePath = bundle.getString(SetupEncryptionDialogFragment.ARG_FILE_PATH, null); - if (fileRemotePath == null) { - Log_OC.e(TAG, "file path is null"); - return; - } - - OCFile file = mContainerActivity.getStorageManager().getFileByDecryptedRemotePath(fileRemotePath); - if (file == null) { - Log_OC.e(TAG,"file is null, cannot toggle encryption"); - return; - } - - if (file.isRootDirectory()) { - Log_OC.d(TAG, "result of setup encryption triggered in root directory, this call is for " + - "creating encrypted folder"); - createFolder(true); - return; - } - - mContainerActivity.getFileOperationsHelper().toggleEncryption(file, true); - mAdapter.updateFileEncryptionById(file.getRemoteId(), true); - searchFragment = false; - setFileDepth(file); - listDirectory(file, MainApp.isOnlyOnDevice()); - mContainerActivity.onBrowsedDownTo(file); - - int position = mAdapter.getItemPosition(file); - saveIndexAndTopPosition(position); - }); - } - /** * Start the appropriate action(s) on the currently selected files given menu selected by the user. * @@ -1582,7 +1487,7 @@ public void refreshDirectory() { searchFragment = false; if (mFile != null) { - setFabVisible(mFile.canCreateFileAndFolder()); + setFabVisible(canCreateInside(mFile)); } final var currentFile = getCurrentFile(); @@ -1698,9 +1603,10 @@ private void updateLayout() { updateSortButton(); setLayoutSwitchButton(); - setFabVisible(!mHideFab); + boolean readOnly = isReadOnlyFolder(mFile); + setFabVisible(!mHideFab && !readOnly); slideHideBottomBehaviourForBottomNavigationView(!mHideFab); - setFabEnabled(mFile != null && (mFile.canCreateFileAndFolder() || mFile.isOfflineOperation())); + setFabEnabled(mFile != null && !readOnly && (mFile.canCreateFileAndFolder() || mFile.isOfflineOperation())); invalidateActionMode(); } @@ -1813,7 +1719,7 @@ public void onMessageEvent(ChangeMenuEvent changeMenuEvent) { } if (mFile != null) { - setFabVisible(mFile.canCreateFileAndFolder()); + setFabVisible(canCreateInside(mFile)); } slideHideBottomBehaviourForBottomNavigationView(true); @@ -1967,160 +1873,7 @@ private RemoteOperation getRecentFilesSearchRemoteOperation() { @Subscribe(threadMode = ThreadMode.MAIN) public void onMessageEvent(EncryptionEvent event) { - new Thread(() -> {{ - final User user = accountManager.getUser(); - - // check if keys are stored - String publicKey = arbitraryDataProvider.getValue(user, EncryptionUtils.PUBLIC_KEY); - String privateKey = arbitraryDataProvider.getValue(user, EncryptionUtils.PRIVATE_KEY); - - FileDataStorageManager storageManager = mContainerActivity.getStorageManager(); - OCFile file = storageManager.getFileByRemoteId(event.getRemoteId()); - - if (publicKey.isEmpty() || privateKey.isEmpty()) { - Log_OC.d(TAG, "no public key for " + user.getAccountName()); - - - requireActivity().runOnUiThread(() -> { - SetupEncryptionDialogFragment dialog = SetupEncryptionDialogFragment.newInstance(user, file.getRemotePath()); - dialog.show(getParentFragmentManager(), SETUP_ENCRYPTION_DIALOG_TAG); - }); - } else { - // TODO E2E: if encryption fails, to not set it as encrypted! - encryptFolder(file, - event.getLocalId(), - event.getRemoteId(), - event.getRemotePath(), - event.getShouldBeEncrypted(), - publicKey, - privateKey, - storageManager); - } - }}).start(); - } - - private void encryptFolder(OCFile folder, - long localId, - String remoteId, - String remotePath, - boolean shouldBeEncrypted, - String publicKeyString, - String privateKeyString, - FileDataStorageManager storageManager) { - try { - Log_OC.d(TAG, "encrypt folder " + folder.getRemoteId()); - User user = accountManager.getUser(); - OwnCloudClient client = clientFactory.create(user); - final var remoteOperationResult = new ToggleEncryptionRemoteOperation(localId, - remotePath, - shouldBeEncrypted) - .execute(client); - - if (remoteOperationResult.isSuccess()) { - OCCapability ocCapability = mContainerActivity.getStorageManager().getCapability(user.getAccountName()); - final var isE2EEV2 = E2EVersionHelper.INSTANCE.isV2Plus(ocCapability); - long e2eCounter = EncryptionUtils.E2E_V1_INITIAL_COUNTER; - if (isE2EEV2) { - e2eCounter = EncryptionUtils.E2E_V2_INITIAL_COUNTER; - } - - // lock folder - String token = EncryptionUtils.lockFolder(folder, client, e2eCounter); - - if (E2EVersionHelper.INSTANCE.isV2Plus(ocCapability)) { - // Update metadata - Pair metadataPair = EncryptionUtils.retrieveMetadata(folder, - client, - privateKeyString, - publicKeyString, - storageManager, - user, - requireContext(), - arbitraryDataProvider); - - boolean metadataExists = metadataPair.first; - DecryptedFolderMetadataFile metadata = metadataPair.second; - - new EncryptionUtilsV2().serializeAndUploadMetadata(folder, - metadata, - token, - client, - metadataExists, - requireContext(), - user, - storageManager); - - // unlock folder - EncryptionUtils.unlockFolder(folder, client, token); - - - } else if (E2EVersionHelper.INSTANCE.isV1(ocCapability)) { - // new metadata - String publicKey = arbitraryDataProvider.getValue(user.getAccountName(), EncryptionUtils.PUBLIC_KEY); - - DecryptedFolderMetadataFileV1 metadata = new DecryptedFolderMetadataFileV1(); - metadata.setMetadata(new DecryptedMetadata()); - - final var e2eeVersion = storageManager.getE2EEVersionObject(user); - final var e2eeVersionAsString = e2eeVersion.getValue(); - metadata.getMetadata().setVersion(Double.parseDouble(e2eeVersionAsString)); - - metadata.getMetadata().setMetadataKeys(new HashMap<>()); - String metadataKey = EncryptionUtils.encodeBytesToBase64String(EncryptionUtils.generateKey()); - String encryptedMetadataKey = EncryptionUtils.encryptStringAsymmetric(metadataKey, publicKey); - metadata.getMetadata().setMetadataKey(encryptedMetadataKey); - - EncryptedFolderMetadataFileV1 encryptedFolderMetadata = EncryptionUtils.encryptFolderMetadata(metadata, - publicKey, - folder.getLocalId(), - user, - arbitraryDataProvider - ); - String serializedFolderMetadata = EncryptionUtils.serializeJSON(encryptedFolderMetadata); - - // upload metadata - EncryptionUtils.uploadMetadata(folder, - serializedFolderMetadata, - token, - client, - false, - e2eeVersion, - "", - arbitraryDataProvider, - user); - - // unlock folder - EncryptionUtils.unlockFolderV1(folder, client, token); - } else if (ocCapability.getEndToEndEncryptionApiVersion() == E2EVersion.UNKNOWN) { - throw new IllegalArgumentException("Unknown E2E version"); - } - - requireActivity().runOnUiThread(() -> { - boolean isFileExists = (mAdapter.getFileByRemoteId(remoteId) != null); - if (!isFileExists) { - OCFile newFile = storageManager.getFileByRemoteId(remoteId); - mAdapter.insertFile(newFile); - } - - mAdapter.updateFileEncryptionById(remoteId, shouldBeEncrypted); - }); - } else if (remoteOperationResult.getHttpCode() == HttpStatus.SC_FORBIDDEN && getRecyclerView() != null) { - requireActivity().runOnUiThread(() -> Snackbar.make(getRecyclerView(), - R.string.end_to_end_encryption_folder_not_empty, - Snackbar.LENGTH_LONG).show()); - } else { - requireActivity().runOnUiThread(() -> {{ - if (getRecyclerView() != null) { - Snackbar.make(getRecyclerView(), - R.string.common_error_unknown, - Snackbar.LENGTH_LONG).show(); - } - }}); - } - - } catch (Throwable e) { - Log_OC.e(TAG, "Error creating encrypted folder", e); - } + event.onResult(this); } @Subscribe(threadMode = ThreadMode.BACKGROUND) diff --git a/app/src/main/java/com/owncloud/android/ui/helpers/FileOperationsHelper.java b/app/src/main/java/com/owncloud/android/ui/helpers/FileOperationsHelper.java index a46a61f86edf..5b01b7693136 100755 --- a/app/src/main/java/com/owncloud/android/ui/helpers/FileOperationsHelper.java +++ b/app/src/main/java/com/owncloud/android/ui/helpers/FileOperationsHelper.java @@ -37,6 +37,7 @@ import com.nextcloud.client.jobs.upload.FileUploadWorker; import com.nextcloud.client.network.ConnectivityService; import com.nextcloud.utils.EditorUtils; +import com.nextcloud.utils.extensions.OCFileExtensionsKt; import com.owncloud.android.MainApp; import com.owncloud.android.R; import com.owncloud.android.datamodel.ArbitraryDataProvider; @@ -944,10 +945,7 @@ public void toggleEncryption(OCFile file, boolean shouldBeEncrypted) { } Log_OC.d(TAG, "toggling encryption for: " + file.getRemotePath()); - EventBus.getDefault().post(new EncryptionEvent(file.getLocalId(), - file.getRemoteId(), - file.getRemotePath(), - shouldBeEncrypted)); + EventBus.getDefault().post(OCFileExtensionsKt.toEncryptionEvent(file, shouldBeEncrypted)); } public void toggleFileLock(OCFile file, boolean shouldBeLocked) { diff --git a/app/src/main/java/com/owncloud/android/utils/DisplayUtils.java b/app/src/main/java/com/owncloud/android/utils/DisplayUtils.java index c689415f3042..a678575caba3 100644 --- a/app/src/main/java/com/owncloud/android/utils/DisplayUtils.java +++ b/app/src/main/java/com/owncloud/android/utils/DisplayUtils.java @@ -504,6 +504,39 @@ public static String getData(InputStream inputStream) { } // region snackbar + public static Snackbar createAndShowSnackMessage(Fragment fragment, @StringRes int messageResource) { + if (fragment == null) { + Log_OC.e(TAG, "snackbar cannot be shown fragment is null"); + return null; + } + + final var activity = fragment.getActivity(); + if (activity == null) { + Log_OC.e(TAG, "snackbar cannot be shown activity is null"); + return null; + } + + final var snackbar = Snackbar.make( + activity.findViewById(android.R.id.content), + messageResource, + Snackbar.LENGTH_INDEFINITE); + + var fab = findFABView(activity); + if (fab != null && fab.getVisibility() == View.VISIBLE) { + snackbar.setAnchorView(fab); + } + + mainLooper.post(snackbar::show); + return snackbar; + } + + public static void dismissSnackMessage(@Nullable Snackbar snackbar) { + if (snackbar == null) { + return; + } + mainLooper.post(snackbar::dismiss); + } + public static void showSnackMessage(Fragment fragment, @StringRes int messageResource) { if (fragment == null) { Log_OC.e(TAG, "snackbar cannot be shown fragment is null"); diff --git a/app/src/main/java/com/owncloud/android/utils/EncryptionUtils.java b/app/src/main/java/com/owncloud/android/utils/EncryptionUtils.java index 0a77ebdd0b6b..42fca11c449f 100644 --- a/app/src/main/java/com/owncloud/android/utils/EncryptionUtils.java +++ b/app/src/main/java/com/owncloud/android/utils/EncryptionUtils.java @@ -425,12 +425,16 @@ public static DecryptedFolderMetadataFileV1 decryptFolderMetaData(EncryptedFolde E2EVersion version = E2EVersionHelper.INSTANCE.fromMetadata(serializedEncryptedMetadata); if (E2EVersionHelper.INSTANCE.isV2Plus(version)) { - EncryptionUtilsV2 encryptionUtilsV2 = new EncryptionUtilsV2(); - return encryptionUtilsV2.parseAnyMetadata(getMetadataOperationResult.getResultData(), - user, - client, - context, - folder); + try { + return new EncryptionUtilsV2().parseAnyMetadata(getMetadataOperationResult.getResultData(), + user, + client, + context, + folder); + } catch (Exception e) { + Log_OC.e(TAG, "Could not decrypt metadata for " + folder.getDecryptedFileName(), e); + return null; + } } else if (E2EVersionHelper.INSTANCE.isV1(version)) { ArbitraryDataProvider arbitraryDataProvider = new ArbitraryDataProviderImpl(context); String privateKey = arbitraryDataProvider.getValue(user.getAccountName(), EncryptionUtils.PRIVATE_KEY); diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9f21f2174191..4acb64b38f68 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,5 +1,4 @@ - -