Upgrade to SDK 34 (#5790)

* change the overridden method signature as per API 34

* add version check condition to compare with API 23 before adding flag

* refactor: add final keywords, fix typo, and remove redundant spaces

For optimized code only

* upgrade: migrate to SDK 34 and upgrade APG

Additionally, add Jetpack Compose to the project

* AndroidManifest: add new permission for API 34

DescriptionActivity should not be exposed

* refactor: permission should not be check on onCreate for some cases

* add method to get correct storage permission and check partial access

Additionally, add final keywords to reduce compiler warnings

* refactor: prevent app from crashing for SDKs >= 34

* add new UI component to allows user to manage partially access photos

Implement using composeView

* change the overridden method signature as per API 34

* add version check condition to compare with API 23 before adding flag

* refactor: add final keywords, fix typo, and remove redundant spaces

For optimized code only

* upgrade: migrate to SDK 34 and upgrade APG

Additionally, add Jetpack Compose to the project

* AndroidManifest: add new permission for API 34

DescriptionActivity should not be exposed

* refactor: permission should not be check on onCreate for some cases

* add method to get correct storage permission and check partial access

Additionally, add final keywords to reduce compiler warnings

* refactor: prevent app from crashing for SDKs >= 34

* add new UI component to allows user to manage partially access photos

Implement using composeView

* replace deprecated circular progress bar with material progress bar

* remove redundant appcompat dependency

* add condition to check for partial access on API >= 34

It prevents invoking photo picker on UploadActivity.

* UploadWorker: add foreground service type

* fix typos in UploadWorker.kt

* add permission to access media location
This commit is contained in:
Rohit Verma 2024-09-17 20:28:44 +05:30 committed by GitHub
parent eb027b74ce
commit 3e915f9848
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 433 additions and 200 deletions

View file

@ -51,6 +51,21 @@ dependencies {
implementation 'com.karumi:dexter:5.0.0' implementation 'com.karumi:dexter:5.0.0'
implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0' implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0'
// Jetpack Compose
def composeBom = platform('androidx.compose:compose-bom:2024.08.00')
implementation "androidx.activity:activity-compose:1.9.1"
implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.8.4"
implementation (composeBom)
implementation "androidx.compose.runtime:runtime"
implementation "androidx.compose.ui:ui"
implementation "androidx.compose.ui:ui-graphics"
implementation "androidx.compose.ui:ui-tooling"
implementation "androidx.compose.foundation:foundation"
implementation "androidx.compose.foundation:foundation-layout"
implementation "androidx.compose.material3:material3"
androidTestImplementation(composeBom)
implementation "com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:$ADAPTER_DELEGATES_VERSION" implementation "com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:$ADAPTER_DELEGATES_VERSION"
implementation "com.hannesdorfmann:adapterdelegates4-pagination:$ADAPTER_DELEGATES_VERSION" implementation "com.hannesdorfmann:adapterdelegates4-pagination:$ADAPTER_DELEGATES_VERSION"
implementation "androidx.paging:paging-runtime-ktx:$PAGING_VERSION" implementation "androidx.paging:paging-runtime-ktx:$PAGING_VERSION"
@ -186,7 +201,7 @@ project.gradle.taskGraph.whenReady {
} }
android { android {
compileSdkVersion 33 compileSdkVersion 34
defaultConfig { defaultConfig {
//applicationId 'fr.free.nrw.commons' //applicationId 'fr.free.nrw.commons'
@ -196,7 +211,7 @@ android {
setProperty("archivesBaseName", "app-commons-v$versionName-" + getBranchName()) setProperty("archivesBaseName", "app-commons-v$versionName-" + getBranchName())
minSdkVersion 21 minSdkVersion 21
targetSdkVersion 33 targetSdkVersion 34
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
testInstrumentationRunnerArguments clearPackageData: 'true' testInstrumentationRunnerArguments clearPackageData: 'true'
@ -253,11 +268,12 @@ android {
} }
} }
debug { debug {
testCoverageEnabled true
minifyEnabled false minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.txt' proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.txt'
testProguardFile 'test-proguard-rules.txt' testProguardFile 'test-proguard-rules.txt'
versionNameSuffix "-debug-" + getBranchName() versionNameSuffix "-debug-" + getBranchName()
enableUnitTestCoverage true
enableAndroidTestCoverage true
} }
} }
@ -354,13 +370,17 @@ android {
targetCompatibility JavaVersion.VERSION_11 targetCompatibility JavaVersion.VERSION_11
} }
kotlinOptions { kotlinOptions {
jvmTarget = "1.8" jvmTarget = "11"
} }
buildToolsVersion buildToolsVersion buildToolsVersion buildToolsVersion
buildFeatures { buildFeatures {
viewBinding true viewBinding true
compose true
}
composeOptions {
kotlinCompilerExtensionVersion '1.3.2'
} }
namespace 'fr.free.nrw.commons' namespace 'fr.free.nrw.commons'
lint { lint {

View file

@ -3,23 +3,29 @@
xmlns:tools="http://schemas.android.com/tools"> xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" /> <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.READ_SYNC_SETTINGS" /> <uses-permission android:name="android.permission.READ_SYNC_SETTINGS" />
<uses-permission android:name="android.permission.READ_SYNC_STATS" /> <uses-permission android:name="android.permission.READ_SYNC_STATS" />
<uses-permission android:name="android.permission.REORDER_TASKS" /> <uses-permission android:name="android.permission.REORDER_TASKS" />
<uses-permission android:name="android.permission.WRITE_SYNC_SETTINGS" /> <uses-permission android:name="android.permission.WRITE_SYNC_SETTINGS" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" /> <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="29"/>
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.AUTHENTICATE_ACCOUNTS" /> <uses-permission android:name="android.permission.AUTHENTICATE_ACCOUNTS" />
<uses-permission android:name="android.permission.GET_ACCOUNTS" /> <uses-permission android:name="android.permission.GET_ACCOUNTS" />
<uses-permission android:name="android.permission.USE_CREDENTIALS" /> <uses-permission android:name="android.permission.USE_CREDENTIALS" />
<uses-permission android:name="android.permission.MANAGE_ACCOUNTS" /> <uses-permission android:name="android.permission.MANAGE_ACCOUNTS" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" /> <uses-permission android:name="android.permission.READ_MEDIA_IMAGES"
android:minSdkVersion="33"/>
<uses-permission android:name="com.google.android.apps.photos.permission.GOOGLE_PHOTOS" /> <uses-permission android:name="com.google.android.apps.photos.permission.GOOGLE_PHOTOS" />
<uses-permission android:name="android.permission.SET_WALLPAPER" /> <uses-permission android:name="android.permission.SET_WALLPAPER" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.ACCESS_MEDIA_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_MEDIA_LOCATION"/>
<uses-permission android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED"
android:minSdkVersion="34"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<queries> <queries>
@ -184,6 +190,10 @@
android:exported="false" android:exported="false"
android:process=":acra" /> android:process=":acra" />
<service
android:name="androidx.work.impl.foreground.SystemForegroundService"
android:foregroundServiceType="dataSync" />
<provider <provider
android:name=".filepicker.ExtendedFileProvider" android:name=".filepicker.ExtendedFileProvider"
android:authorities="${applicationId}.provider" android:authorities="${applicationId}.provider"

View file

@ -165,15 +165,16 @@ public class MainActivity extends BaseActivity
* so that location in the EXIF metadata of the images shared by the user * so that location in the EXIF metadata of the images shared by the user
* is retained on devices running Android 10 or above * is retained on devices running Android 10 or above
*/ */
if (VERSION.SDK_INT >= VERSION_CODES.Q) { // if (VERSION.SDK_INT >= VERSION_CODES.Q) {
PermissionUtils.checkPermissionsAndPerformAction( // ActivityCompat.requestPermissions(this,
this, // new String[]{Manifest.permission.ACCESS_MEDIA_LOCATION}, 0);
() -> { // PermissionUtils.checkPermissionsAndPerformAction(
}, // this,
R.string.media_location_permission_denied, // () -> {},
R.string.add_location_manually, // R.string.media_location_permission_denied,
permission.ACCESS_MEDIA_LOCATION); // R.string.add_location_manually,
} // permission.ACCESS_MEDIA_LOCATION);
// }
checkAndResumeStuckUploads(); checkAndResumeStuckUploads();
} }
} }

View file

@ -40,14 +40,14 @@ open class OnSwipeTouchListener(context: Context?) : View.OnTouchListener {
* Detects the gestures * Detects the gestures
*/ */
override fun onFling( override fun onFling(
event1: MotionEvent, event1: MotionEvent?,
event2: MotionEvent, event2: MotionEvent,
velocityX: Float, velocityX: Float,
velocityY: Float velocityY: Float
): Boolean { ): Boolean {
try { try {
val diffY: Float = event2.y - event1.y val diffY: Float = event2.y - (event1?.y ?: event2.y)
val diffX: Float = event2.x - event1.x val diffX: Float = event2.x - (event1?.x ?: event2.x)
if (abs(diffX) > abs(diffY)) { if (abs(diffX) > abs(diffY)) {
if (abs(diffX) > SWIPE_THRESHOLD_WIDTH && abs(velocityX) > if (abs(diffX) > SWIPE_THRESHOLD_WIDTH && abs(velocityX) >
SWIPE_VELOCITY_THRESHOLD) { SWIPE_VELOCITY_THRESHOLD) {

View file

@ -62,13 +62,15 @@ class FolderAdapter(
folder.images.removeAll(toBeRemoved) folder.images.removeAll(toBeRemoved)
val count = folder.images.size val count = folder.images.size
if(count == 0) { if(count == 0 && folders.size > 0) {
// Folder is empty, remove folder from the adapter. // Folder is empty, remove folder from the adapter.
holder.itemView.post{ holder.itemView.post{
val updatePosition = folders.indexOf(folder) val updatePosition = folders.indexOf(folder)
folders.removeAt(updatePosition) if(updatePosition != -1) {
notifyItemRemoved(updatePosition) folders.removeAt(updatePosition)
notifyItemRangeChanged(updatePosition, folders.size) notifyItemRemoved(updatePosition)
notifyItemRangeChanged(updatePosition, folders.size)
}
} }
} else { } else {
val previewImage = folder.images[0] val previewImage = folder.images[0]

View file

@ -122,7 +122,7 @@ class ImageAdapter(
* Bind View holder, load image, selected view, click listeners. * Bind View holder, load image, selected view, click listeners.
*/ */
override fun onBindViewHolder(holder: ImageViewHolder, position: Int) { override fun onBindViewHolder(holder: ImageViewHolder, position: Int) {
if(images.size == 0) { return }
var image=images[position] var image=images[position]
holder.image.setImageDrawable (null) holder.image.setImageDrawable (null)
if (context.contentResolver.getType(image.uri) == null) { if (context.contentResolver.getType(image.uri) == null) {

View file

@ -1,16 +1,49 @@
package fr.free.nrw.commons.customselector.ui.selector package fr.free.nrw.commons.customselector.ui.selector
import android.Manifest
import android.app.Activity import android.app.Activity
import android.app.Dialog import android.app.Dialog
import android.content.Intent import android.content.Intent
import android.content.SharedPreferences import android.content.SharedPreferences
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.util.Log
import android.view.View import android.view.View
import android.view.Window import android.view.Window
import android.widget.Button import android.widget.Button
import android.widget.ImageButton import android.widget.ImageButton
import android.widget.TextView import android.widget.TextView
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.constraintlayout.widget.ConstraintLayout import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.content.ContextCompat
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import fr.free.nrw.commons.R import fr.free.nrw.commons.R
import fr.free.nrw.commons.customselector.database.NotForUploadStatus import fr.free.nrw.commons.customselector.database.NotForUploadStatus
@ -24,10 +57,12 @@ import fr.free.nrw.commons.databinding.ActivityCustomSelectorBinding
import fr.free.nrw.commons.databinding.CustomSelectorBottomLayoutBinding import fr.free.nrw.commons.databinding.CustomSelectorBottomLayoutBinding
import fr.free.nrw.commons.databinding.CustomSelectorToolbarBinding import fr.free.nrw.commons.databinding.CustomSelectorToolbarBinding
import fr.free.nrw.commons.filepicker.Constants import fr.free.nrw.commons.filepicker.Constants
import fr.free.nrw.commons.filepicker.FilePicker
import fr.free.nrw.commons.media.ZoomableActivity import fr.free.nrw.commons.media.ZoomableActivity
import fr.free.nrw.commons.theme.BaseActivity import fr.free.nrw.commons.theme.BaseActivity
import fr.free.nrw.commons.upload.FileUtilsWrapper import fr.free.nrw.commons.upload.FileUtilsWrapper
import fr.free.nrw.commons.utils.CustomSelectorUtils import fr.free.nrw.commons.utils.CustomSelectorUtils
import fr.free.nrw.commons.utils.PermissionUtils
import kotlinx.coroutines.* import kotlinx.coroutines.*
import java.io.File import java.io.File
import java.lang.Integer.max import java.lang.Integer.max
@ -114,14 +149,37 @@ class CustomSelectorActivity : BaseActivity(), FolderClickListener, ImageSelectL
private var progressDialogText:String="" private var progressDialogText:String=""
private var showPartialAccessIndicator by mutableStateOf(false)
/** /**
* onCreate Activity, sets theme, initialises the view model, setup view. * onCreate Activity, sets theme, initialises the view model, setup view.
*/ */
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE &&
ContextCompat.checkSelfPermission(
this, Manifest.permission.READ_MEDIA_IMAGES
) == PackageManager.PERMISSION_DENIED
) {
showPartialAccessIndicator = true
}
binding = ActivityCustomSelectorBinding.inflate(layoutInflater) binding = ActivityCustomSelectorBinding.inflate(layoutInflater)
toolbarBinding = CustomSelectorToolbarBinding.bind(binding.root) toolbarBinding = CustomSelectorToolbarBinding.bind(binding.root)
bottomSheetBinding = CustomSelectorBottomLayoutBinding.bind(binding.root) bottomSheetBinding = CustomSelectorBottomLayoutBinding.bind(binding.root)
binding.partialAccessIndicator.setContent {
PartialStorageAccessIndicator(
isVisible = showPartialAccessIndicator,
onManage = {
if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
requestPermissions(arrayOf(Manifest.permission.READ_MEDIA_IMAGES), 1)
}
},
modifier = Modifier
.padding(vertical = 8.dp, horizontal = 4.dp)
.fillMaxWidth()
)
}
val view = binding.root val view = binding.root
setContentView(view) setContentView(view)
@ -147,6 +205,24 @@ class CustomSelectorActivity : BaseActivity(), FolderClickListener, ImageSelectL
} }
} }
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
if(requestCode == 1 && Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
if(grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
showPartialAccessIndicator = false
}
}
}
override fun onResume() {
super.onResume()
fetchData()
}
/** /**
* When data will be send from full screen mode, it will be passed to fragment * When data will be send from full screen mode, it will be passed to fragment
*/ */
@ -181,7 +257,6 @@ class CustomSelectorActivity : BaseActivity(), FolderClickListener, ImageSelectL
supportFragmentManager.beginTransaction() supportFragmentManager.beginTransaction()
.replace(R.id.fragment_container, FolderFragment.newInstance()) .replace(R.id.fragment_container, FolderFragment.newInstance())
.commit() .commit()
fetchData()
setUpToolbar() setUpToolbar()
setUpBottomLayout() setUpBottomLayout()
} }
@ -498,3 +573,52 @@ class CustomSelectorActivity : BaseActivity(), FolderClickListener, ImageSelectL
const val ITEM_ID: String = "ItemId" const val ITEM_ID: String = "ItemId"
} }
} }
@Composable
fun PartialStorageAccessIndicator(
isVisible: Boolean,
onManage: ()-> Unit,
modifier: Modifier = Modifier
) {
if(isVisible) {
OutlinedCard(
modifier = modifier,
colors = CardDefaults.cardColors(
containerColor = colorResource(R.color.primarySuperLightColor)
),
border = BorderStroke(0.5.dp, color = colorResource(R.color.primaryColor)),
shape = RoundedCornerShape(8.dp)
) {
Row(modifier = Modifier.padding(16.dp).fillMaxWidth()) {
Text(
text = "You've given access to a select number of photos",
modifier = Modifier.weight(1f)
)
TextButton(
onClick = onManage,
modifier = Modifier.align(Alignment.Bottom),
colors = ButtonDefaults.buttonColors(
containerColor = colorResource(R.color.primaryColor)
),
shape = RoundedCornerShape(8.dp)
) {
Text(
text = "Manage",
style = MaterialTheme.typography.labelMedium,
color = colorResource(R.color.primaryTextColor)
)
}
}
}
}
}
@Preview
@Composable
fun PartialStorageAccessIndicatorPreview() {
Surface {
PartialStorageAccessIndicator(isVisible = true, onManage = {}, modifier = Modifier
.padding(vertical = 8.dp, horizontal = 4.dp)
.fillMaxWidth()
)
}
}

View file

@ -116,11 +116,14 @@ class FolderFragment : CommonsDaggerSupportFragment() {
private fun handleResult(result: Result) { private fun handleResult(result: Result) {
if(result.status is CallbackStatus.SUCCESS){ if(result.status is CallbackStatus.SUCCESS){
val images = result.images val images = result.images
if(images.isNullOrEmpty()) if(images.isEmpty()){
{
binding?.emptyText?.let { binding?.emptyText?.let {
it.visibility = View.VISIBLE it.visibility = View.VISIBLE
} }
} else {
binding?.emptyText?.let {
it.visibility = View.GONE
}
} }
folders = ImageHelper.folderListFromImages(result.images) folders = ImageHelper.folderListFromImages(result.images)
folderAdapter.init(folders) folderAdapter.init(folders)

View file

@ -39,6 +39,7 @@ import fr.free.nrw.commons.upload.FileUtilsWrapper
import io.reactivex.schedulers.Schedulers import io.reactivex.schedulers.Schedulers
import java.util.* import java.util.*
import javax.inject.Inject import javax.inject.Inject
import kotlin.collections.ArrayList
/** /**
* Custom Selector Image Fragment. * Custom Selector Image Fragment.
@ -279,6 +280,8 @@ class ImageFragment : CommonsDaggerSupportFragment(), RefreshUIListener, PassDat
} }
} }
} else { } else {
filteredImages = ArrayList()
allImages = filteredImages
binding?.emptyText?.let { binding?.emptyText?.let {
it.visibility = View.VISIBLE it.visibility = View.VISIBLE
} }
@ -324,7 +327,7 @@ class ImageFragment : CommonsDaggerSupportFragment(), RefreshUIListener, PassDat
.findFirstVisibleItemPosition() .findFirstVisibleItemPosition()
// Check for empty RecyclerView. // Check for empty RecyclerView.
if (position != -1) { if (position != -1 && filteredImages.size > 0) {
context?.let { context -> context?.let { context ->
context.getSharedPreferences( context.getSharedPreferences(
"CustomSelector", "CustomSelector",

View file

@ -4,16 +4,12 @@ import android.app.NotificationManager;
import android.app.PendingIntent; import android.app.PendingIntent;
import android.content.Context; import android.content.Context;
import android.content.Intent; import android.content.Intent;
import android.os.Build; import android.os.Build;
import androidx.core.app.NotificationCompat; import androidx.core.app.NotificationCompat;
import javax.inject.Inject; import javax.inject.Inject;
import javax.inject.Singleton; import javax.inject.Singleton;
import fr.free.nrw.commons.CommonsApplication; import fr.free.nrw.commons.CommonsApplication;
import fr.free.nrw.commons.R; import fr.free.nrw.commons.R;
import static androidx.core.app.NotificationCompat.DEFAULT_ALL; import static androidx.core.app.NotificationCompat.DEFAULT_ALL;
import static androidx.core.app.NotificationCompat.PRIORITY_HIGH; import static androidx.core.app.NotificationCompat.PRIORITY_HIGH;
@ -30,11 +26,11 @@ public class NotificationHelper {
public static final int NOTIFICATION_EDIT_DESCRIPTION = 4; public static final int NOTIFICATION_EDIT_DESCRIPTION = 4;
public static final int NOTIFICATION_EDIT_DEPICTIONS = 5; public static final int NOTIFICATION_EDIT_DEPICTIONS = 5;
private NotificationManager notificationManager; private final NotificationManager notificationManager;
private NotificationCompat.Builder notificationBuilder; private final NotificationCompat.Builder notificationBuilder;
@Inject @Inject
public NotificationHelper(Context context) { public NotificationHelper(final Context context) {
notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
notificationBuilder = new NotificationCompat notificationBuilder = new NotificationCompat
.Builder(context, CommonsApplication.NOTIFICATION_CHANNEL_ID_ALL) .Builder(context, CommonsApplication.NOTIFICATION_CHANNEL_ID_ALL)
@ -49,12 +45,13 @@ public class NotificationHelper {
* @param notificationId the notificationID * @param notificationId the notificationID
* @param intent the intent to be fired when the notification is clicked * @param intent the intent to be fired when the notification is clicked
*/ */
public void showNotification(Context context, public void showNotification(
String notificationTitle, final Context context,
String notificationMessage, final String notificationTitle,
int notificationId, final String notificationMessage,
Intent intent) { final int notificationId,
final Intent intent
) {
notificationBuilder.setDefaults(DEFAULT_ALL) notificationBuilder.setDefaults(DEFAULT_ALL)
.setContentTitle(notificationTitle) .setContentTitle(notificationTitle)
.setStyle(new NotificationCompat.BigTextStyle() .setStyle(new NotificationCompat.BigTextStyle()
@ -65,14 +62,11 @@ public class NotificationHelper {
.setPriority(PRIORITY_HIGH); .setPriority(PRIORITY_HIGH);
int flags = PendingIntent.FLAG_UPDATE_CURRENT; int flags = PendingIntent.FLAG_UPDATE_CURRENT;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
// Check if the API level is 31 or higher to modify the flag flags |= PendingIntent.FLAG_IMMUTABLE; // This flag was introduced in API 23
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
// For API level 31 or above, PendingIntent requires either FLAG_IMMUTABLE or FLAG_MUTABLE to be set
flags |= PendingIntent.FLAG_IMMUTABLE;
} }
PendingIntent pendingIntent = PendingIntent.getActivity(context, 1, intent, flags); final PendingIntent pendingIntent = PendingIntent.getActivity(context, 1, intent, flags);
notificationBuilder.setContentIntent(pendingIntent); notificationBuilder.setContentIntent(pendingIntent);
notificationManager.notify(notificationId, notificationBuilder.build()); notificationManager.notify(notificationId, notificationBuilder.build());
} }

View file

@ -278,7 +278,8 @@ public class UploadActivity extends BaseActivity implements UploadContract.View,
public void checkStoragePermissions() { public void checkStoragePermissions() {
// Check if all required permissions are granted // Check if all required permissions are granted
final boolean hasAllPermissions = PermissionUtils.hasPermission(this, PERMISSIONS_STORAGE); final boolean hasAllPermissions = PermissionUtils.hasPermission(this, PERMISSIONS_STORAGE);
if (hasAllPermissions) { final boolean hasPartialAccess = PermissionUtils.hasPartialAccess(this);
if (hasAllPermissions || hasPartialAccess) {
// All required permissions are granted, so enable UI elements and perform actions // All required permissions are granted, so enable UI elements and perform actions
receiveSharedItems(); receiveSharedItems();
binding.cvContainerTopCard.setVisibility(View.VISIBLE); binding.cvContainerTopCard.setVisibility(View.VISIBLE);

View file

@ -6,6 +6,7 @@ import android.app.PendingIntent
import android.app.TaskStackBuilder import android.app.TaskStackBuilder
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.ServiceInfo
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.os.Build import android.os.Build
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
@ -46,13 +47,12 @@ import java.util.*
import java.util.regex.Pattern import java.util.regex.Pattern
import javax.inject.Inject import javax.inject.Inject
class UploadWorker(
class UploadWorker(var appContext: Context, workerParams: WorkerParameters) : private var appContext: Context, workerParams: WorkerParameters
CoroutineWorker(appContext, workerParams) { ): CoroutineWorker(appContext, workerParams) {
private var notificationManager: NotificationManagerCompat? = null private var notificationManager: NotificationManagerCompat? = null
@Inject @Inject
lateinit var wikidataEditService: WikidataEditService lateinit var wikidataEditService: WikidataEditService
@ -83,12 +83,11 @@ class UploadWorker(var appContext: Context, workerParams: WorkerParameters) :
//Attributes of the current-upload notification //Attributes of the current-upload notification
private var currentNotificationID: Int = -1// lateinit is not allowed with primitives private var currentNotificationID: Int = -1// lateinit is not allowed with primitives
private lateinit var currentNotificationTag: String private lateinit var currentNotificationTag: String
private var curentNotification: NotificationCompat.Builder private var currentNotification: NotificationCompat.Builder
private val statesToProcess= ArrayList<Int>() private val statesToProcess= ArrayList<Int>()
private val STASH_ERROR_CODES = Arrays private val STASH_ERROR_CODES = listOf(
.asList(
"uploadstash-file-not-found", "uploadstash-file-not-found",
"stashfailed", "stashfailed",
"verification-error", "verification-error",
@ -100,7 +99,7 @@ class UploadWorker(var appContext: Context, workerParams: WorkerParameters) :
.getInstance(appContext) .getInstance(appContext)
.commonsApplicationComponent .commonsApplicationComponent
.inject(this) .inject(this)
curentNotification = currentNotification =
getNotificationBuilder(CommonsApplication.NOTIFICATION_CHANNEL_ID_ALL)!! getNotificationBuilder(CommonsApplication.NOTIFICATION_CHANNEL_ID_ALL)!!
statesToProcess.add(Contribution.STATE_QUEUED) statesToProcess.add(Contribution.STATE_QUEUED)
@ -120,21 +119,23 @@ class UploadWorker(var appContext: Context, workerParams: WorkerParameters) :
fun onProgress(transferred: Long, total: Long) { fun onProgress(transferred: Long, total: Long) {
if (transferred == total) { if (transferred == total) {
// Completed! // Completed!
curentNotification.setContentTitle(notificationFinishingTitle) currentNotification.setContentTitle(notificationFinishingTitle)
.setProgress(0, 100, true) .setProgress(0, 100, true)
} else { } else {
curentNotification currentNotification
.setProgress( .setProgress(
100, 100,
(transferred.toDouble() / total.toDouble() * 100).toInt(), (transferred.toDouble() / total.toDouble() * 100).toInt(),
false false
) )
} }
notificationManager?.cancel(PROCESSING_UPLOADS_NOTIFICATION_TAG, PROCESSING_UPLOADS_NOTIFICATION_ID) notificationManager?.cancel(
PROCESSING_UPLOADS_NOTIFICATION_TAG, PROCESSING_UPLOADS_NOTIFICATION_ID
)
notificationManager?.notify( notificationManager?.notify(
currentNotificationTag, currentNotificationTag,
currentNotificationID, currentNotificationID,
curentNotification.build()!! currentNotification.build()
) )
contribution!!.transferred = transferred contribution!!.transferred = transferred
contributionDao.update(contribution).blockingAwait() contributionDao.update(contribution).blockingAwait()
@ -248,10 +249,18 @@ class UploadWorker(var appContext: Context, workerParams: WorkerParameters) :
* Create new notification for foreground service * Create new notification for foreground service
*/ */
private fun createForegroundInfo(): ForegroundInfo { private fun createForegroundInfo(): ForegroundInfo {
return ForegroundInfo( return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
1, ForegroundInfo(
createNotificationForForegroundService() 1,
) createNotificationForForegroundService(),
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
)
} else {
ForegroundInfo(
1,
createNotificationForForegroundService()
)
}
} }
override suspend fun getForegroundInfo(): ForegroundInfo { override suspend fun getForegroundInfo(): ForegroundInfo {
@ -282,9 +291,9 @@ class UploadWorker(var appContext: Context, workerParams: WorkerParameters) :
currentNotificationID = currentNotificationID =
(contribution.localUri.toString() + contribution.media.filename).hashCode() (contribution.localUri.toString() + contribution.media.filename).hashCode()
curentNotification currentNotification
getNotificationBuilder(CommonsApplication.NOTIFICATION_CHANNEL_ID_ALL)!! getNotificationBuilder(CommonsApplication.NOTIFICATION_CHANNEL_ID_ALL)!!
curentNotification.setContentTitle( currentNotification.setContentTitle(
appContext.getString( appContext.getString(
R.string.upload_progress_notification_title_start, R.string.upload_progress_notification_title_start,
displayTitle displayTitle
@ -294,7 +303,7 @@ class UploadWorker(var appContext: Context, workerParams: WorkerParameters) :
notificationManager?.notify( notificationManager?.notify(
currentNotificationTag, currentNotificationTag,
currentNotificationID, currentNotificationID,
curentNotification.build()!! currentNotification.build()
) )
val filename = media.filename val filename = media.filename
@ -312,14 +321,16 @@ class UploadWorker(var appContext: Context, workerParams: WorkerParameters) :
val stashUploadResult = uploadClient.uploadFileToStash( val stashUploadResult = uploadClient.uploadFileToStash(
filename!!, contribution, notificationProgressUpdater filename!!, contribution, notificationProgressUpdater
).onErrorReturn{ ).onErrorReturn{
return@onErrorReturn StashUploadResult(StashUploadState.FAILED,fileKey = null,errorMessage = it.message) return@onErrorReturn StashUploadResult(
StashUploadState.FAILED,fileKey = null,errorMessage = it.message
)
}.blockingSingle() }.blockingSingle()
when (stashUploadResult.state) { when (stashUploadResult.state) {
StashUploadState.SUCCESS -> { StashUploadState.SUCCESS -> {
//If the stash upload succeeds //If the stash upload succeeds
Timber.d("Upload to stash success for fileName: $filename") Timber.d("Upload to stash success for fileName: $filename")
Timber.d("Ensure uniqueness of filename"); Timber.d("Ensure uniqueness of filename")
val uniqueFileName = findUniqueFileName(filename!!) val uniqueFileName = findUniqueFileName(filename)
try { try {
//Upload the file from stash //Upload the file from stash
@ -335,7 +346,7 @@ class UploadWorker(var appContext: Context, workerParams: WorkerParameters) :
) )
wikidataEditService.addDepictionsAndCaptions(uploadResult, contribution) wikidataEditService.addDepictionsAndCaptions(uploadResult, contribution)
.blockingSubscribe(); .blockingSubscribe()
if(contribution.wikidataPlace==null){ if(contribution.wikidataPlace==null){
Timber.d( Timber.d(
"WikiDataEdit not required, upload success" "WikiDataEdit not required, upload success"
@ -378,12 +389,15 @@ class UploadWorker(var appContext: Context, workerParams: WorkerParameters) :
} }
else -> { else -> {
Timber.e("""upload file to stash failed with status: ${stashUploadResult.state}""") Timber.e("""upload file to stash failed with status: ${stashUploadResult.state}""")
contribution.state = Contribution.STATE_FAILED contribution.state = Contribution.STATE_FAILED
contribution.chunkInfo = null contribution.chunkInfo = null
contribution.errorInfo = stashUploadResult.errorMessage contribution.errorInfo = stashUploadResult.errorMessage
showErrorNotification(contribution) showErrorNotification(contribution)
contributionDao.saveSynchronous(contribution) contributionDao.saveSynchronous(contribution)
if (stashUploadResult.errorMessage.equals(CsrfTokenClient.INVALID_TOKEN_ERROR_MESSAGE)) { if (stashUploadResult.errorMessage.equals(
CsrfTokenClient.INVALID_TOKEN_ERROR_MESSAGE)
) {
Timber.e("Invalid Login, logging out") Timber.e("Invalid Login, logging out")
showInvalidLoginNotification(contribution) showInvalidLoginNotification(contribution)
val username = sessionManager.userName val username = sessionManager.userName
@ -475,7 +489,7 @@ class UploadWorker(var appContext: Context, workerParams: WorkerParameters) :
imageSha1 == modifiedSha1, imageSha1 == modifiedSha1,
true true
) )
); )
} }
} }
} }
@ -519,8 +533,8 @@ class UploadWorker(var appContext: Context, workerParams: WorkerParameters) :
private fun showSuccessNotification(contribution: Contribution) { private fun showSuccessNotification(contribution: Contribution) {
val displayTitle = contribution.media.displayTitle val displayTitle = contribution.media.displayTitle
contribution.state=Contribution.STATE_COMPLETED contribution.state=Contribution.STATE_COMPLETED
curentNotification.setContentIntent(getPendingIntent(MainActivity::class.java)) currentNotification.setContentIntent(getPendingIntent(MainActivity::class.java))
curentNotification.setContentTitle( currentNotification.setContentTitle(
appContext.getString( appContext.getString(
R.string.upload_completed_notification_title, R.string.upload_completed_notification_title,
displayTitle displayTitle
@ -531,7 +545,7 @@ class UploadWorker(var appContext: Context, workerParams: WorkerParameters) :
.setOngoing(false) .setOngoing(false)
notificationManager?.notify( notificationManager?.notify(
currentNotificationTag, currentNotificationID, currentNotificationTag, currentNotificationID,
curentNotification.build() currentNotification.build()
) )
} }
@ -542,8 +556,8 @@ class UploadWorker(var appContext: Context, workerParams: WorkerParameters) :
@SuppressLint("StringFormatInvalid") @SuppressLint("StringFormatInvalid")
private fun showFailedNotification(contribution: Contribution) { private fun showFailedNotification(contribution: Contribution) {
val displayTitle = contribution.media.displayTitle val displayTitle = contribution.media.displayTitle
curentNotification.setContentIntent(getPendingIntent(UploadProgressActivity::class.java)) currentNotification.setContentIntent(getPendingIntent(UploadProgressActivity::class.java))
curentNotification.setContentTitle( currentNotification.setContentTitle(
appContext.getString( appContext.getString(
R.string.upload_failed_notification_title, R.string.upload_failed_notification_title,
displayTitle displayTitle
@ -554,13 +568,13 @@ class UploadWorker(var appContext: Context, workerParams: WorkerParameters) :
.setOngoing(false) .setOngoing(false)
notificationManager?.notify( notificationManager?.notify(
currentNotificationTag, currentNotificationID, currentNotificationTag, currentNotificationID,
curentNotification.build() currentNotification.build()
) )
} }
@SuppressLint("StringFormatInvalid") @SuppressLint("StringFormatInvalid")
private fun showInvalidLoginNotification(contribution: Contribution) { private fun showInvalidLoginNotification(contribution: Contribution) {
val displayTitle = contribution.media.displayTitle val displayTitle = contribution.media.displayTitle
curentNotification.setContentTitle( currentNotification.setContentTitle(
appContext.getString( appContext.getString(
R.string.upload_failed_notification_title, R.string.upload_failed_notification_title,
displayTitle displayTitle
@ -571,7 +585,7 @@ class UploadWorker(var appContext: Context, workerParams: WorkerParameters) :
.setOngoing(false) .setOngoing(false)
notificationManager?.notify( notificationManager?.notify(
currentNotificationTag, currentNotificationID, currentNotificationTag, currentNotificationID,
curentNotification.build() currentNotification.build()
) )
} }
@ -581,7 +595,7 @@ class UploadWorker(var appContext: Context, workerParams: WorkerParameters) :
@SuppressLint("StringFormatInvalid") @SuppressLint("StringFormatInvalid")
private fun showErrorNotification(contribution: Contribution) { private fun showErrorNotification(contribution: Contribution) {
val displayTitle = contribution.media.displayTitle val displayTitle = contribution.media.displayTitle
curentNotification.setContentTitle( currentNotification.setContentTitle(
appContext.getString( appContext.getString(
R.string.upload_failed_notification_title, R.string.upload_failed_notification_title,
displayTitle displayTitle
@ -592,7 +606,7 @@ class UploadWorker(var appContext: Context, workerParams: WorkerParameters) :
.setOngoing(false) .setOngoing(false)
notificationManager?.notify( notificationManager?.notify(
currentNotificationTag, currentNotificationID, currentNotificationTag, currentNotificationID,
curentNotification.build() currentNotification.build()
) )
} }
@ -602,8 +616,9 @@ class UploadWorker(var appContext: Context, workerParams: WorkerParameters) :
*/ */
private fun showPausedNotification(contribution: Contribution) { private fun showPausedNotification(contribution: Contribution) {
val displayTitle = contribution.media.displayTitle val displayTitle = contribution.media.displayTitle
curentNotification.setContentIntent(getPendingIntent(UploadProgressActivity::class.java))
curentNotification.setContentTitle( currentNotification.setContentIntent(getPendingIntent(UploadProgressActivity::class.java))
currentNotification.setContentTitle(
appContext.getString( appContext.getString(
R.string.upload_paused_notification_title, R.string.upload_paused_notification_title,
displayTitle displayTitle
@ -614,7 +629,7 @@ class UploadWorker(var appContext: Context, workerParams: WorkerParameters) :
.setOngoing(false) .setOngoing(false)
notificationManager!!.notify( notificationManager!!.notify(
currentNotificationTag, currentNotificationID, currentNotificationTag, currentNotificationID,
curentNotification.build() currentNotification.build()
) )
} }
@ -624,8 +639,8 @@ class UploadWorker(var appContext: Context, workerParams: WorkerParameters) :
*/ */
private fun showCancelledNotification(contribution: Contribution) { private fun showCancelledNotification(contribution: Contribution) {
val displayTitle = contribution.media.displayTitle val displayTitle = contribution.media.displayTitle
curentNotification.setContentIntent(getPendingIntent(UploadProgressActivity::class.java)) currentNotification.setContentIntent(getPendingIntent(UploadProgressActivity::class.java))
curentNotification.setContentTitle( currentNotification.setContentTitle(
displayTitle displayTitle
) )
.setContentText("Upload has been cancelled!") .setContentText("Upload has been cancelled!")
@ -633,7 +648,7 @@ class UploadWorker(var appContext: Context, workerParams: WorkerParameters) :
.setOngoing(false) .setOngoing(false)
notificationManager!!.notify( notificationManager!!.notify(
currentNotificationTag, currentNotificationID, currentNotificationTag, currentNotificationID,
curentNotification.build() currentNotification.build()
) )
} }
@ -652,6 +667,6 @@ class UploadWorker(var appContext: Context, workerParams: WorkerParameters) :
} else { } else {
getPendingIntent(0, PendingIntent.FLAG_UPDATE_CURRENT) getPendingIntent(0, PendingIntent.FLAG_UPDATE_CURRENT)
} }
}; }
} }
} }

View file

@ -1,6 +1,7 @@
package fr.free.nrw.commons.utils; package fr.free.nrw.commons.utils;
import android.Manifest; import android.Manifest;
import android.Manifest.permission;
import android.app.Activity; import android.app.Activity;
import android.content.Intent; import android.content.Intent;
import android.content.pm.PackageManager; import android.content.pm.PackageManager;
@ -20,24 +21,26 @@ import fr.free.nrw.commons.R;
import fr.free.nrw.commons.upload.UploadActivity; import fr.free.nrw.commons.upload.UploadActivity;
import java.util.List; import java.util.List;
public class PermissionUtils { public class PermissionUtils {
public static String[] PERMISSIONS_STORAGE = getPermissionsStorage();
public static String[] PERMISSIONS_STORAGE = isSDKVersionScopedStorageCompatible() ? static String[] getPermissionsStorage() {
isSDKVersionTiramisu() ? new String[]{ if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
Manifest.permission.READ_MEDIA_IMAGES} : return new String[]{ Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED,
new String[]{Manifest.permission.READ_EXTERNAL_STORAGE} Manifest.permission.READ_MEDIA_IMAGES,
: isSDKVersionTiramisu() ? new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE, Manifest.permission.ACCESS_MEDIA_LOCATION };
Manifest.permission.READ_MEDIA_IMAGES} }
: new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE, if(Build.VERSION.SDK_INT == Build.VERSION_CODES.TIRAMISU) {
Manifest.permission.READ_EXTERNAL_STORAGE}; return new String[]{ Manifest.permission.READ_MEDIA_IMAGES,
Manifest. permission.ACCESS_MEDIA_LOCATION };
private static boolean isSDKVersionScopedStorageCompatible() { }
return Build.VERSION.SDK_INT > Build.VERSION_CODES.P; if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
} return new String[]{ Manifest.permission.READ_EXTERNAL_STORAGE,
Manifest.permission.ACCESS_MEDIA_LOCATION };
public static boolean isSDKVersionTiramisu() { }
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU; return new String[]{
Manifest.permission.READ_EXTERNAL_STORAGE,
Manifest.permission.WRITE_EXTERNAL_STORAGE };
} }
/** /**
@ -45,11 +48,11 @@ public class PermissionUtils {
* blocked(marked never ask again by the user) It open the app settings from where the user can * blocked(marked never ask again by the user) It open the app settings from where the user can
* manually give us the required permission. * manually give us the required permission.
* *
* @param activity * @param activity The Activity which requires a permission which has been blocked
*/ */
private static void askUserToManuallyEnablePermissionFromSettings(Activity activity) { private static void askUserToManuallyEnablePermissionFromSettings(final Activity activity) {
Intent intent = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS); final Intent intent = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS);
Uri uri = Uri.fromParts("package", activity.getPackageName(), null); final Uri uri = Uri.fromParts("package", activity.getPackageName(), null);
intent.setData(uri); intent.setData(uri);
activity.startActivityForResult(intent, activity.startActivityForResult(intent,
CommonsApplication.OPEN_APPLICATION_DETAIL_SETTINGS); CommonsApplication.OPEN_APPLICATION_DETAIL_SETTINGS);
@ -58,14 +61,13 @@ public class PermissionUtils {
/** /**
* Checks whether the app already has a particular permission * Checks whether the app already has a particular permission
* *
* @param activity * @param activity The Activity context to check permissions against
* @param permissions permissions to be checked * @param permissions An array of permission strings to check
* @return * @return `true if the app has all the specified permissions, `false` otherwise
*/ */
public static boolean hasPermission(Activity activity, String permissions[]) { public static boolean hasPermission(final Activity activity, final String[] permissions) {
boolean hasPermission = true; boolean hasPermission = true;
for (String permission : permissions for(final String permission : permissions) {
) {
hasPermission = hasPermission && hasPermission = hasPermission &&
ContextCompat.checkSelfPermission(activity, permission) ContextCompat.checkSelfPermission(activity, permission)
== PackageManager.PERMISSION_GRANTED; == PackageManager.PERMISSION_GRANTED;
@ -73,6 +75,17 @@ public class PermissionUtils {
return hasPermission; return hasPermission;
} }
public static boolean hasPartialAccess(final Activity activity) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
return ContextCompat.checkSelfPermission(activity,
permission.READ_MEDIA_VISUAL_USER_SELECTED
) == PackageManager.PERMISSION_GRANTED && ContextCompat.checkSelfPermission(
activity, permission.READ_MEDIA_IMAGES
) == PackageManager.PERMISSION_DENIED;
}
return false;
}
/** /**
* Checks for a particular permission and runs the runnable to perform an action when the * Checks for a particular permission and runs the runnable to perform an action when the
* permission is granted Also, it shows a rationale if needed * permission is granted Also, it shows a rationale if needed
@ -99,9 +112,17 @@ public class PermissionUtils {
* @param rationaleMessage rationale message to be displayed when permission was denied. It * @param rationaleMessage rationale message to be displayed when permission was denied. It
* can be an invalid @StringRes * can be an invalid @StringRes
*/ */
public static void checkPermissionsAndPerformAction(Activity activity, public static void checkPermissionsAndPerformAction(
Runnable onPermissionGranted, @StringRes int rationaleTitle, final Activity activity,
@StringRes int rationaleMessage, String... permissions) { final Runnable onPermissionGranted,
final @StringRes int rationaleTitle,
final @StringRes int rationaleMessage,
final String... permissions
) {
if (hasPartialAccess(activity)) {
onPermissionGranted.run();
return;
}
checkPermissionsAndPerformAction(activity, onPermissionGranted, null, checkPermissionsAndPerformAction(activity, onPermissionGranted, null,
rationaleTitle, rationaleMessage, permissions); rationaleTitle, rationaleMessage, permissions);
} }
@ -125,25 +146,30 @@ public class PermissionUtils {
* @param rationaleTitle rationale title to be displayed when permission was denied * @param rationaleTitle rationale title to be displayed when permission was denied
* @param rationaleMessage rationale message to be displayed when permission was denied * @param rationaleMessage rationale message to be displayed when permission was denied
*/ */
public static void checkPermissionsAndPerformAction(Activity activity, public static void checkPermissionsAndPerformAction(
Runnable onPermissionGranted, Runnable onPermissionDenied, @StringRes int rationaleTitle, final Activity activity,
@StringRes int rationaleMessage, String... permissions) { final Runnable onPermissionGranted,
final Runnable onPermissionDenied,
final @StringRes int rationaleTitle,
final @StringRes int rationaleMessage,
final String... permissions
) {
Dexter.withActivity(activity) Dexter.withActivity(activity)
.withPermissions(permissions) .withPermissions(permissions)
.withListener(new MultiplePermissionsListener() { .withListener(new MultiplePermissionsListener() {
@Override @Override
public void onPermissionsChecked(MultiplePermissionsReport report) { public void onPermissionsChecked(final MultiplePermissionsReport report) {
if (report.areAllPermissionsGranted()) { if (report.areAllPermissionsGranted() || hasPartialAccess(activity)) {
onPermissionGranted.run(); onPermissionGranted.run();
return; return;
} }
if (report.isAnyPermissionPermanentlyDenied()) { if (report.isAnyPermissionPermanentlyDenied()) {
// permission is denied permanently, we will show user a dialog message. // permission is denied permanently, we will show user a dialog message.
DialogUtil.showAlertDialog(activity, activity.getString(rationaleTitle), DialogUtil.showAlertDialog(
activity, activity.getString(rationaleTitle),
activity.getString(rationaleMessage), activity.getString(rationaleMessage),
activity.getString(R.string.navigation_item_settings), activity.getString(R.string.navigation_item_settings),
null, null, () -> {
() -> {
askUserToManuallyEnablePermissionFromSettings(activity); askUserToManuallyEnablePermissionFromSettings(activity);
if (activity instanceof UploadActivity) { if (activity instanceof UploadActivity) {
((UploadActivity) activity).setShowPermissionsDialog(true); ((UploadActivity) activity).setShowPermissionsDialog(true);
@ -158,13 +184,16 @@ public class PermissionUtils {
} }
@Override @Override
public void onPermissionRationaleShouldBeShown(List<PermissionRequest> permissions, public void onPermissionRationaleShouldBeShown(
PermissionToken token) { final List<PermissionRequest> permissions,
final PermissionToken token
) {
if (rationaleTitle == -1 && rationaleMessage == -1) { if (rationaleTitle == -1 && rationaleMessage == -1) {
token.continuePermissionRequest(); token.continuePermissionRequest();
return; return;
} }
DialogUtil.showAlertDialog(activity, activity.getString(rationaleTitle), DialogUtil.showAlertDialog(
activity, activity.getString(rationaleTitle),
activity.getString(rationaleMessage), activity.getString(rationaleMessage),
activity.getString(android.R.string.ok), activity.getString(android.R.string.ok),
activity.getString(android.R.string.cancel), activity.getString(android.R.string.cancel),
@ -173,24 +202,19 @@ public class PermissionUtils {
((UploadActivity) activity).setShowPermissionsDialog(true); ((UploadActivity) activity).setShowPermissionsDialog(true);
} }
token.continuePermissionRequest(); token.continuePermissionRequest();
} },
,
() -> { () -> {
Toast.makeText(activity.getApplicationContext(), Toast.makeText(activity.getApplicationContext(),
R.string.permissions_are_required_for_functionality, R.string.permissions_are_required_for_functionality,
Toast.LENGTH_LONG) Toast.LENGTH_LONG
.show(); ).show();
token.cancelPermissionRequest(); token.cancelPermissionRequest();
if (activity instanceof UploadActivity) { if (activity instanceof UploadActivity) {
activity.finish(); activity.finish();
} }
} }, null, false
, );
null,
false);
} }
}) }).onSameThread().check();
.onSameThread()
.check();
} }
} }

View file

@ -9,10 +9,9 @@ import android.graphics.Bitmap;
import android.graphics.Canvas; import android.graphics.Canvas;
import android.graphics.Paint; import android.graphics.Paint;
import android.net.Uri; import android.net.Uri;
import android.os.Build;
import android.widget.RemoteViews; import android.widget.RemoteViews;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import com.facebook.common.executors.CallerThreadExecutor; import com.facebook.common.executors.CallerThreadExecutor;
import com.facebook.common.references.CloseableReference; import com.facebook.common.references.CloseableReference;
import com.facebook.datasource.DataSource; import com.facebook.datasource.DataSource;
@ -22,10 +21,8 @@ import com.facebook.imagepipeline.datasource.BaseBitmapDataSubscriber;
import com.facebook.imagepipeline.image.CloseableImage; import com.facebook.imagepipeline.image.CloseableImage;
import com.facebook.imagepipeline.request.ImageRequest; import com.facebook.imagepipeline.request.ImageRequest;
import com.facebook.imagepipeline.request.ImageRequestBuilder; import com.facebook.imagepipeline.request.ImageRequestBuilder;
import fr.free.nrw.commons.media.MediaClient; import fr.free.nrw.commons.media.MediaClient;
import javax.inject.Inject; import javax.inject.Inject;
import fr.free.nrw.commons.R; import fr.free.nrw.commons.R;
import fr.free.nrw.commons.contributions.MainActivity; import fr.free.nrw.commons.contributions.MainActivity;
import fr.free.nrw.commons.di.ApplicationlessInjection; import fr.free.nrw.commons.di.ApplicationlessInjection;
@ -41,17 +38,28 @@ import static android.content.Intent.ACTION_VIEW;
*/ */
public class PicOfDayAppWidget extends AppWidgetProvider { public class PicOfDayAppWidget extends AppWidgetProvider {
private CompositeDisposable compositeDisposable = new CompositeDisposable(); private final CompositeDisposable compositeDisposable = new CompositeDisposable();
@Inject @Inject
MediaClient mediaClient; MediaClient mediaClient;
void updateAppWidget(Context context, AppWidgetManager appWidgetManager, int appWidgetId) { void updateAppWidget(
RemoteViews views = new RemoteViews(context.getPackageName(), R.layout.pic_of_day_app_widget); final Context context,
final AppWidgetManager appWidgetManager,
final int appWidgetId
) {
final RemoteViews views = new RemoteViews(
context.getPackageName(), R.layout.pic_of_day_app_widget);
// Launch App on Button Click // Launch App on Button Click
Intent viewIntent = new Intent(context, MainActivity.class); final Intent viewIntent = new Intent(context, MainActivity.class);
PendingIntent pendingIntent = PendingIntent.getActivity(context, 0, viewIntent, PendingIntent.FLAG_IMMUTABLE); int flags = PendingIntent.FLAG_UPDATE_CURRENT;
if (Build.VERSION.SDK_INT>= Build.VERSION_CODES.M) {
flags |= PendingIntent.FLAG_IMMUTABLE;
}
final PendingIntent pendingIntent = PendingIntent.getActivity(
context, 0, viewIntent, flags);
views.setOnClickPendingIntent(R.id.camera_button, pendingIntent); views.setOnClickPendingIntent(R.id.camera_button, pendingIntent);
appWidgetManager.updateAppWidget(appWidgetId, views); appWidgetManager.updateAppWidget(appWidgetId, views);
@ -60,61 +68,76 @@ public class PicOfDayAppWidget extends AppWidgetProvider {
/** /**
* Loads the picture of the day using media wiki API * Loads the picture of the day using media wiki API
* @param context * @param context The application context.
* @param views * @param views The RemoteViews object used to update the App Widget UI.
* @param appWidgetManager * @param appWidgetManager The AppWidgetManager instance for managing the widget.
* @param appWidgetId * @param appWidgetId he ID of the App Widget to update.
*/ */
private void loadPictureOfTheDay(Context context, private void loadPictureOfTheDay(
RemoteViews views, final Context context,
AppWidgetManager appWidgetManager, final RemoteViews views,
int appWidgetId) { final AppWidgetManager appWidgetManager,
final int appWidgetId
) {
compositeDisposable.add(mediaClient.getPictureOfTheDay() compositeDisposable.add(mediaClient.getPictureOfTheDay()
.subscribeOn(Schedulers.io()) .subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
.subscribe( .subscribe(
response -> { response -> {
if (response != null) { if (response != null) {
views.setTextViewText(R.id.appwidget_title, response.getDisplayTitle()); views.setTextViewText(R.id.appwidget_title, response.getDisplayTitle());
// View in browser // View in browser
Intent viewIntent = new Intent(); final Intent viewIntent = new Intent();
viewIntent.setAction(ACTION_VIEW); viewIntent.setAction(ACTION_VIEW);
viewIntent.setData(Uri.parse(response.getPageTitle().getMobileUri())); viewIntent.setData(Uri.parse(response.getPageTitle().getMobileUri()));
PendingIntent pendingIntent = PendingIntent.getActivity(context, 0, viewIntent, PendingIntent.FLAG_IMMUTABLE);
views.setOnClickPendingIntent(R.id.appwidget_image, pendingIntent);
loadImageFromUrl(response.getThumbUrl(), context, views, appWidgetManager, appWidgetId); int flags = PendingIntent.FLAG_UPDATE_CURRENT;
if (Build.VERSION.SDK_INT>= Build.VERSION_CODES.M) {
flags |= PendingIntent.FLAG_IMMUTABLE;
} }
}, final PendingIntent pendingIntent = PendingIntent.getActivity(
t -> Timber.e(t, "Fetching picture of the day failed") context, 0, viewIntent, flags);
views.setOnClickPendingIntent(R.id.appwidget_image, pendingIntent);
loadImageFromUrl(response.getThumbUrl(),
context, views, appWidgetManager, appWidgetId);
}
},
t -> Timber.e(t, "Fetching picture of the day failed")
)); ));
} }
/** /**
* Uses Fresco to load an image from Url * Uses Fresco to load an image from Url
* @param imageUrl * @param imageUrl The URL of the image to load.
* @param context * @param context The application context.
* @param views * @param views The RemoteViews object used to update the App Widget UI.
* @param appWidgetManager * @param appWidgetManager The AppWidgetManager instance for managing the widget.
* @param appWidgetId * @param appWidgetId he ID of the App Widget to update.
*/ */
private void loadImageFromUrl(String imageUrl, private void loadImageFromUrl(
Context context, final String imageUrl,
RemoteViews views, final Context context,
AppWidgetManager appWidgetManager, final RemoteViews views,
int appWidgetId) { final AppWidgetManager appWidgetManager,
ImageRequest request = ImageRequestBuilder.newBuilderWithSource(Uri.parse(imageUrl)).build(); final int appWidgetId
ImagePipeline imagePipeline = Fresco.getImagePipeline(); ) {
DataSource<CloseableReference<CloseableImage>> dataSource final ImageRequest request = ImageRequestBuilder
= imagePipeline.fetchDecodedImage(request, context); .newBuilderWithSource(Uri.parse(imageUrl)).build();
final ImagePipeline imagePipeline = Fresco.getImagePipeline();
final DataSource<CloseableReference<CloseableImage>> dataSource = imagePipeline
.fetchDecodedImage(request, context);
dataSource.subscribe(new BaseBitmapDataSubscriber() { dataSource.subscribe(new BaseBitmapDataSubscriber() {
@Override @Override
protected void onNewResultImpl(@Nullable Bitmap tempBitmap) { protected void onNewResultImpl(@Nullable final Bitmap tempBitmap) {
Bitmap bitmap = null; Bitmap bitmap = null;
if (tempBitmap != null) { if (tempBitmap != null) {
bitmap = Bitmap.createBitmap(tempBitmap.getWidth(), tempBitmap.getHeight(), Bitmap.Config.ARGB_8888); bitmap = Bitmap.createBitmap(
Canvas canvas = new Canvas(bitmap); tempBitmap.getWidth(), tempBitmap.getHeight(), Bitmap.Config.ARGB_8888
);
final Canvas canvas = new Canvas(bitmap);
canvas.drawBitmap(tempBitmap, 0f, 0f, new Paint()); canvas.drawBitmap(tempBitmap, 0f, 0f, new Paint());
} }
views.setImageViewBitmap(R.id.appwidget_image, bitmap); views.setImageViewBitmap(R.id.appwidget_image, bitmap);
@ -122,32 +145,37 @@ public class PicOfDayAppWidget extends AppWidgetProvider {
} }
@Override @Override
protected void onFailureImpl(DataSource<CloseableReference<CloseableImage>> dataSource) { protected void onFailureImpl(
final DataSource<CloseableReference<CloseableImage>> dataSource
) {
// Ignore failure for now. // Ignore failure for now.
} }
}, CallerThreadExecutor.getInstance()); }, CallerThreadExecutor.getInstance());
} }
@Override @Override
public void onUpdate(Context context, AppWidgetManager appWidgetManager, int[] appWidgetIds) { public void onUpdate(
final Context context,
final AppWidgetManager appWidgetManager,
final int[] appWidgetIds
) {
ApplicationlessInjection ApplicationlessInjection
.getInstance(context .getInstance(context.getApplicationContext())
.getApplicationContext())
.getCommonsApplicationComponent() .getCommonsApplicationComponent()
.inject(this); .inject(this);
// There may be multiple widgets active, so update all of them // There may be multiple widgets active, so update all of them
for (int appWidgetId : appWidgetIds) { for (final int appWidgetId : appWidgetIds) {
updateAppWidget(context, appWidgetManager, appWidgetId); updateAppWidget(context, appWidgetManager, appWidgetId);
} }
} }
@Override @Override
public void onEnabled(Context context) { public void onEnabled(final Context context) {
// Enter relevant functionality for when the first widget is created // Enter relevant functionality for when the first widget is created
} }
@Override @Override
public void onDisabled(Context context) { public void onDisabled(final Context context) {
// Enter relevant functionality for when the last widget is disabled // Enter relevant functionality for when the last widget is disabled
} }
} }

View file

@ -1,17 +1,25 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent"> android:layout_height="match_parent">
<include layout="@layout/custom_selector_toolbar" /> <include layout="@layout/custom_selector_toolbar" />
<androidx.compose.ui.platform.ComposeView
android:id="@+id/partial_access_indicator"
android:layout_width="match_parent"
android:layout_height="0dp"
app:layout_constraintTop_toBottomOf="@id/toolbar_layout"/>
<androidx.fragment.app.FragmentContainerView <androidx.fragment.app.FragmentContainerView
android:id="@+id/fragment_container" android:id="@+id/fragment_container"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="0dp" android:layout_height="0dp"
app:layout_constraintBottom_toTopOf="@id/bottom_layout" app:layout_constraintBottom_toTopOf="@id/bottom_layout"
app:layout_constraintTop_toBottomOf="@+id/toolbar_layout" /> app:layout_constraintTop_toBottomOf="@+id/partial_access_indicator"
tools:layout_editor_absoluteX="-16dp" />
<include layout="@layout/custom_selector_bottom_layout" /> <include layout="@layout/custom_selector_bottom_layout" />

View file

@ -6,7 +6,7 @@ buildscript {
maven { url "https://plugins.gradle.org/m2/" } maven { url "https://plugins.gradle.org/m2/" }
} }
dependencies { dependencies {
classpath 'com.android.tools.build:gradle:8.0.2' classpath 'com.android.tools.build:gradle:8.5.0'
classpath 'com.getkeepsafe.dexcount:dexcount-gradle-plugin:0.8.2' classpath 'com.getkeepsafe.dexcount:dexcount-gradle-plugin:0.8.2'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$KOTLIN_VERSION" classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$KOTLIN_VERSION"
classpath 'org.codehaus.groovy:groovy-all:2.4.15' classpath 'org.codehaus.groovy:groovy-all:2.4.15'

View file

@ -1,6 +1,6 @@
#Sun Apr 23 18:22:54 IST 2023 #Sun Apr 23 18:22:54 IST 2023
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.1.1-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists