diff --git a/.github/workflows/android-ci-comment.yml b/.github/workflows/android-ci-comment.yml new file mode 100644 index 000000000..64422e284 --- /dev/null +++ b/.github/workflows/android-ci-comment.yml @@ -0,0 +1,84 @@ +name: Android CI Comment + +on: [pull_request_target] + +permissions: + issues: write + +jobs: + comment: + name: Comment on PR with APK links + runs-on: ubuntu-latest + steps: + - name: Checkout base branch + uses: actions/checkout@v3 + with: + ref: ${{ github.base_ref }} + + - name: Download Run ID Artifact + uses: actions/download-artifact@v4 + with: + name: run-id + + - name: Read Run ID + id: read-run-id + run: echo "RUN_ID=$(cat run_id.txt)" >> $GITHUB_ENV + + - name: Comment on PR with APK download links + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + uses: actions/github-script@v6 + with: + script: | + try { + const token = process.env.GH_TOKEN; + if (!token) { + throw new Error('GITHUB_TOKEN is not set.'); + } + + const runId = "${{ env.RUN_ID }}"; + if (!runId) { + throw new Error('Run ID not found.'); + } + + const { data: { artifacts } } = await github.rest.actions.listWorkflowRunArtifacts({ + owner: context.repo.owner, + repo: context.repo.repo, + run_id: runId + }); + + if (!artifacts || artifacts.length === 0) { + console.log('No artifacts found for this workflow run.'); + return; + } + + const betaArtifact = artifacts.find(artifact => artifact.name === "betaDebugAPK"); + const prodArtifact = artifacts.find(artifact => artifact.name === "prodDebugAPK"); + + if (!betaArtifact || !prodArtifact) { + console.log('Could not find both Beta and Prod APK artifacts.'); + console.log('Available artifacts:', artifacts.map(a => a.name).join(', ')); + return; + } + + const betaDownloadUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/suites/${runId}/artifacts/${betaArtifact.id}`; + const prodDownloadUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/suites/${runId}/artifacts/${prodArtifact.id}`; + + const commentBody = ` + 📱 **APK for pull request is ready to see the changes** 📱 + - [Download Beta APK](${betaDownloadUrl}) + - [Download Prod APK](${prodDownloadUrl}) + `; + + await github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: commentBody + }); + + console.log('Successfully posted comment with APK download links'); + } catch (error) { + console.error('Error in PR comment creation:', error); + core.setFailed(`Workflow failed: ${error.message}`); + } diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index bcbef52fd..8a744bc0a 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -3,7 +3,6 @@ name: Android CI on: [push, pull_request, workflow_dispatch] permissions: - pull-requests: write contents: read actions: read @@ -107,64 +106,14 @@ jobs: with: name: prodDebugAPK path: app/build/outputs/apk/prod/debug/app-*.apk - - - name: Comment on PR with APK download links + + - name: Store Workflow Run ID if: github.event_name == 'pull_request' - uses: actions/github-script@v6 + run: echo "${{ github.run_id }}" > run_id.txt + + - name: Upload Run ID as Artifact + if: github.event_name == 'pull_request' + uses: actions/upload-artifact@v4 with: - github-token: ${{secrets.GITHUB_TOKEN}} - script: | - try { - const token = process.env.GITHUB_TOKEN; - if (!token) { - throw new Error('GITHUB_TOKEN is not set. Please check workflow permissions.'); - } - - - const { data: { artifacts } } = await github.rest.actions.listWorkflowRunArtifacts({ - owner: context.repo.owner, - repo: context.repo.repo, - run_id: context.runId - }); - - if (!artifacts || artifacts.length === 0) { - console.log('No artifacts found for this workflow run.'); - return; - } - - const betaArtifact = artifacts.find(artifact => artifact.name === "betaDebugAPK"); - const prodArtifact = artifacts.find(artifact => artifact.name === "prodDebugAPK"); - - if (!betaArtifact || !prodArtifact) { - console.log('Could not find both Beta and Prod APK artifacts.'); - console.log('Available artifacts:', artifacts.map(a => a.name).join(', ')); - return; - } - - const betaDownloadUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/suites/${context.runId}/artifacts/${betaArtifact.id}`; - const prodDownloadUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/suites/${context.runId}/artifacts/${prodArtifact.id}`; - - const commentBody = ` - 📱 **APK for pull request is ready to see the changes** 📱 - - [Download Beta APK](${betaDownloadUrl}) - - [Download Prod APK](${prodDownloadUrl}) - `; - - await github.rest.issues.createComment({ - issue_number: context.issue.number, - owner: context.repo.owner, - repo: context.repo.repo, - body: commentBody - }); - - console.log('Successfully posted comment with APK download links'); - } catch (error) { - console.error('Error in PR comment creation:', error); - if (error.message.includes('GITHUB_TOKEN')) { - core.setFailed('Missing or invalid GITHUB_TOKEN. Please check repository secrets configuration.'); - } else if (error.status === 403) { - core.setFailed('Permission denied. Please check workflow permissions in repository settings.'); - } else { - core.setFailed(`Workflow failed: ${error.message}`); - } - } + name: run-id + path: run_id.txt diff --git a/app/.attach_pid781771 b/app/.attach_pid781771 deleted file mode 100644 index e69de29bb..000000000 diff --git a/app/build.gradle b/app/build.gradle index 2bde0d4f1..dbb5458bd 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -175,8 +175,8 @@ dependencies { testImplementation "androidx.work:work-testing:$work_version" //Glide - implementation 'com.github.bumptech.glide:glide:4.12.0' - annotationProcessor 'com.github.bumptech.glide:compiler:4.12.0' + implementation 'com.github.bumptech.glide:glide:4.16.0' + annotationProcessor 'com.github.bumptech.glide:compiler:4.16.0' kaptTest "androidx.databinding:databinding-compiler:8.0.2" kaptAndroidTest "androidx.databinding:databinding-compiler:8.0.2" diff --git a/app/src/main/java/fr/free/nrw/commons/Media.kt b/app/src/main/java/fr/free/nrw/commons/Media.kt index 025302cfd..293321c27 100644 --- a/app/src/main/java/fr/free/nrw/commons/Media.kt +++ b/app/src/main/java/fr/free/nrw/commons/Media.kt @@ -90,6 +90,41 @@ class Media constructor( captions = captions, ) + constructor( + captions: Map, + categories: List?, + filename: String?, + fallbackDescription: String?, + author: String?, + user: String?, + dateUploaded: Date? = Date(), + license: String? = null, + licenseUrl: String? = null, + imageUrl: String? = null, + thumbUrl: String? = null, + coordinates: LatLng? = null, + descriptions: Map = emptyMap(), + depictionIds: List = emptyList(), + categoriesHiddenStatus: Map = emptyMap() + ) : this( + pageId = UUID.randomUUID().toString(), + filename = filename, + fallbackDescription = fallbackDescription, + dateUploaded = dateUploaded, + author = author, + user = user, + categories = categories, + captions = captions, + license = license, + licenseUrl = licenseUrl, + imageUrl = imageUrl, + thumbUrl = thumbUrl, + coordinates = coordinates, + descriptions = descriptions, + depictionIds = depictionIds, + categoriesHiddenStatus = categoriesHiddenStatus + ) + /** * Gets media display title * @return Media title diff --git a/app/src/main/java/fr/free/nrw/commons/media/MediaDetailFragment.kt b/app/src/main/java/fr/free/nrw/commons/media/MediaDetailFragment.kt index 40c9785db..77ff1df0c 100644 --- a/app/src/main/java/fr/free/nrw/commons/media/MediaDetailFragment.kt +++ b/app/src/main/java/fr/free/nrw/commons/media/MediaDetailFragment.kt @@ -16,6 +16,7 @@ import android.view.KeyEvent import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.view.ViewTreeObserver import android.view.ViewTreeObserver.OnGlobalLayoutListener import android.widget.ArrayAdapter import android.widget.Button @@ -405,9 +406,14 @@ class MediaDetailFragment : CommonsDaggerSupportFragment(), CategoryEditHelper.C * Gets the height of the frame layout as soon as the view is ready and updates aspect ratio * of the picture. */ - view.post { - frameLayoutHeight = binding.mediaDetailFrameLayout.measuredHeight - updateAspectRatio(binding.mediaDetailScrollView.width) + view.post{ + val width = binding.mediaDetailScrollView.width + if (width > 0) { + frameLayoutHeight = binding.mediaDetailFrameLayout.measuredHeight + updateAspectRatio(width) + } else { + view.postDelayed({ updateAspectRatio(binding.root.width) }, 1) + } } return view diff --git a/app/src/main/java/fr/free/nrw/commons/media/MediaDetailPagerFragment.java b/app/src/main/java/fr/free/nrw/commons/media/MediaDetailPagerFragment.java index 545e96624..b4b4e9c57 100644 --- a/app/src/main/java/fr/free/nrw/commons/media/MediaDetailPagerFragment.java +++ b/app/src/main/java/fr/free/nrw/commons/media/MediaDetailPagerFragment.java @@ -185,10 +185,12 @@ public class MediaDetailPagerFragment extends CommonsDaggerSupportFragment imple * or a fragment */ private void initProvider() { - if (getParentFragment() != null) { + if (getParentFragment() instanceof MediaDetailProvider) { provider = (MediaDetailProvider) getParentFragment(); - } else { + } else if (getActivity() instanceof MediaDetailProvider) { provider = (MediaDetailProvider) getActivity(); + } else { + throw new ClassCastException("Parent must implement MediaDetailProvider"); } } diff --git a/app/src/main/java/fr/free/nrw/commons/nearby/fragments/NearbyParentFragment.kt b/app/src/main/java/fr/free/nrw/commons/nearby/fragments/NearbyParentFragment.kt index 638084562..0e91e300a 100644 --- a/app/src/main/java/fr/free/nrw/commons/nearby/fragments/NearbyParentFragment.kt +++ b/app/src/main/java/fr/free/nrw/commons/nearby/fragments/NearbyParentFragment.kt @@ -39,11 +39,16 @@ import androidx.appcompat.app.AlertDialog import androidx.constraintlayout.widget.ConstraintLayout import androidx.core.content.ContextCompat import androidx.core.content.FileProvider +import androidx.fragment.app.Fragment import androidx.lifecycle.LifecycleCoroutineScope import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.LinearLayoutManager +import com.bumptech.glide.Glide +import com.bumptech.glide.load.engine.GlideException +import com.bumptech.glide.request.RequestListener +import com.bumptech.glide.request.target.Target import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetBehavior.BottomSheetCallback import com.google.android.material.snackbar.Snackbar @@ -51,6 +56,7 @@ import com.jakewharton.rxbinding2.view.RxView import com.jakewharton.rxbinding3.appcompat.queryTextChanges import fr.free.nrw.commons.CommonsApplication import fr.free.nrw.commons.MapController.NearbyPlacesInfo +import fr.free.nrw.commons.Media import fr.free.nrw.commons.R import fr.free.nrw.commons.Utils import fr.free.nrw.commons.bookmarks.locations.BookmarkLocationsDao @@ -67,6 +73,10 @@ import fr.free.nrw.commons.location.LocationPermissionsHelper.LocationPermission import fr.free.nrw.commons.location.LocationServiceManager import fr.free.nrw.commons.location.LocationServiceManager.LocationChangeType import fr.free.nrw.commons.location.LocationUpdateListener +import fr.free.nrw.commons.media.MediaClient +import fr.free.nrw.commons.media.MediaDetailPagerFragment +import fr.free.nrw.commons.media.MediaDetailPagerFragment.MediaDetailProvider +import fr.free.nrw.commons.navtab.NavTab import fr.free.nrw.commons.nearby.BottomSheetAdapter import fr.free.nrw.commons.nearby.BottomSheetAdapter.ItemClickListener import fr.free.nrw.commons.nearby.CheckBoxTriStates @@ -118,17 +128,26 @@ import timber.log.Timber import java.io.File import java.io.FileOutputStream import java.io.IOException +import java.net.URLDecoder +import java.nio.charset.StandardCharsets import java.text.SimpleDateFormat import java.util.Date import java.util.Locale +import java.util.UUID import java.util.concurrent.TimeUnit import javax.inject.Inject import javax.inject.Named +import javax.sql.DataSource import kotlin.concurrent.Volatile -class NearbyParentFragment : CommonsDaggerSupportFragment(), NearbyParentFragmentContract.View, - WikidataP18EditListener, LocationUpdateListener, LocationPermissionCallback, ItemClickListener { +class NearbyParentFragment : CommonsDaggerSupportFragment(), + NearbyParentFragmentContract.View, + WikidataP18EditListener, + LocationUpdateListener, + LocationPermissionCallback, + ItemClickListener, + MediaDetailPagerFragment.MediaDetailProvider { var binding: FragmentNearbyParentBinding? = null val mapEventsOverlay: MapEventsOverlay = MapEventsOverlay(object : MapEventsReceiver { @@ -163,6 +182,13 @@ class NearbyParentFragment : CommonsDaggerSupportFragment(), NearbyParentFragmen @Named("default_preferences") lateinit var applicationKvStore: JsonKvStore + @Inject + lateinit var mediaClient: MediaClient + + lateinit var mediaDetails: MediaDetailPagerFragment + + lateinit var media: Media + @Inject lateinit var bookmarkLocationDao: BookmarkLocationsDao @@ -716,6 +742,10 @@ class NearbyParentFragment : CommonsDaggerSupportFragment(), NearbyParentFragmen presenter?.attachView(this) registerNetworkReceiver() + binding?.coordinatorLayout?.visibility = View.VISIBLE + binding?.map?.setMultiTouchControls(true) + binding?.map?.isClickable = true + if (isResumed && (activity as? MainActivity)?.activeFragment == ActiveFragment.NEARBY) { if (activity?.let { locationPermissionsHelper?.checkLocationPermission(it) } == true) { locationPermissionGranted() @@ -1853,7 +1883,31 @@ class NearbyParentFragment : CommonsDaggerSupportFragment(), NearbyParentFragmen } fun backButtonClicked(): Boolean { - return presenter!!.backButtonClicked() + if (::mediaDetails.isInitialized && mediaDetails.isVisible) { + removeFragment(mediaDetails) + + binding?.coordinatorLayout?.visibility = View.VISIBLE + binding?.map?.setMultiTouchControls(true) + binding?.map?.isClickable = true + + val transaction = childFragmentManager.beginTransaction() + val fragmentContainer = childFragmentManager.findFragmentById(R.id.coordinator_layout) + + if (fragmentContainer != null) { + transaction.show(fragmentContainer) + } + + transaction.commit() + childFragmentManager.executePendingTransactions() + + (activity as? MainActivity)?.showTabs() + (activity as? MainActivity)?.supportActionBar?.setDisplayHomeAsUpEnabled(false) + return true + } else { + (activity as? MainActivity)?.setSelectedItemId(NavTab.NEARBY.code()) + } + + return presenter?.backButtonClicked() ?: false } override fun onLocationPermissionDenied(toastMessage: String) { @@ -2299,7 +2353,61 @@ class NearbyParentFragment : CommonsDaggerSupportFragment(), NearbyParentFragmen bottomSheetAdapter!!.setClickListener(this) binding!!.bottomSheetDetails.bottomSheetRecyclerView.adapter = bottomSheetAdapter updateBookmarkButtonImage(selectedPlace!!) - binding!!.bottomSheetDetails.icon.setImageResource(selectedPlace!!.label.icon) + + selectedPlace?.pic?.substringAfterLast("/")?.takeIf { it.isNotEmpty() }?.let { imageName -> + Glide.with(binding!!.bottomSheetDetails.icon.context) + .clear(binding!!.bottomSheetDetails.icon) + + val loadingDrawable = ContextCompat.getDrawable( + binding!!.bottomSheetDetails.icon.context, + R.drawable.loading_icon + ) + val animation = AnimationUtils.loadAnimation( + binding!!.bottomSheetDetails.icon.context, + R.anim.rotate + ) + + Glide.with(binding!!.bottomSheetDetails.icon.context) + .load("https://commons.wikimedia.org/wiki/Special:Redirect/file/$imageName?width=25") + .placeholder(loadingDrawable) + .error(selectedPlace!!.label.icon) + .listener(object : RequestListener { + override fun onLoadFailed( + e: GlideException?, + model: Any?, + target: Target, + isFirstResource: Boolean + ): Boolean { + binding!!.bottomSheetDetails.icon.clearAnimation() + return false + } + + override fun onResourceReady( + resource: Drawable, + model: Any, + target: Target?, + dataSource: com.bumptech.glide.load.DataSource, + isFirstResource: Boolean + ): Boolean { + binding!!.bottomSheetDetails.icon.clearAnimation() + return false + } + }) + .into(binding!!.bottomSheetDetails.icon) + + if (binding!!.bottomSheetDetails.icon.drawable != null && binding!!.bottomSheetDetails.icon.drawable.constantState == loadingDrawable?.constantState) { + binding!!.bottomSheetDetails.icon.startAnimation(animation) + } else { + binding!!.bottomSheetDetails.icon.clearAnimation() + } + + binding!!.bottomSheetDetails.icon.setOnClickListener { + handleMediaClick(imageName) + } + } ?: run { + binding!!.bottomSheetDetails.icon.setImageResource(selectedPlace!!.label.icon) + } + binding!!.bottomSheetDetails.title.text = selectedPlace!!.name binding!!.bottomSheetDetails.category.text = selectedPlace!!.distance // Remove label since it is double information @@ -2354,6 +2462,101 @@ class NearbyParentFragment : CommonsDaggerSupportFragment(), NearbyParentFragmen } } + private fun handleMediaClick(imageName: String) { + val decodedImageName = URLDecoder.decode(imageName, StandardCharsets.UTF_8.toString()) + + mediaClient.getMedia("File:$decodedImageName") + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe({ mediaResponse -> + if (mediaResponse != null) { + // Create a Media object from the response + media = Media( + pageId = mediaResponse.pageId ?: UUID.randomUUID().toString(), + thumbUrl = mediaResponse.thumbUrl, + imageUrl = mediaResponse.imageUrl, + filename = mediaResponse.filename, + fallbackDescription = mediaResponse.fallbackDescription, + dateUploaded = mediaResponse.dateUploaded, + license = mediaResponse.license, + licenseUrl = mediaResponse.licenseUrl, + author = mediaResponse.author, + user = mediaResponse.user, + categories = mediaResponse.categories, + coordinates = mediaResponse.coordinates, + captions = mediaResponse.captions ?: emptyMap(), + descriptions = mediaResponse.descriptions ?: emptyMap(), + depictionIds = mediaResponse.depictionIds ?: emptyList(), + categoriesHiddenStatus = mediaResponse.categoriesHiddenStatus ?: emptyMap() + ) + // Remove existing fragment before showing new details + if (::mediaDetails.isInitialized && mediaDetails.isAdded) { + removeFragment(mediaDetails) + } + showMediaDetails() + } else { + Timber.e("Fetched media is null for image: $decodedImageName") + } + }, { throwable -> + Timber.e(throwable, "Error fetching media for image: $decodedImageName") + }) + } + + private fun showMediaDetails() { + binding?.map?.setMultiTouchControls(false) + binding?.map?.isClickable = false + + mediaDetails = MediaDetailPagerFragment.newInstance(false, true) + + + val transaction = childFragmentManager.beginTransaction() + + val fragmentContainer = childFragmentManager.findFragmentById(R.id.coordinator_layout) + if (fragmentContainer != null) { + transaction.hide(fragmentContainer) + } + + // Replace instead of add to ensure new fragment is used + transaction.replace(R.id.coordinator_layout, mediaDetails, "MediaDetailFragmentTag") + transaction.addToBackStack("Nearby_Parent_Fragment_Tag").commit() + childFragmentManager.executePendingTransactions() + + (activity as? MainActivity)?.supportActionBar?.setDisplayHomeAsUpEnabled(true) + + if (mediaDetails.isAdded) { + mediaDetails.showImage(0) + } else { + Timber.e("Error: MediaDetailPagerFragment is NOT added") + } + } + + override fun getMediaAtPosition(i: Int): Media? { + return media + } + + override fun getTotalMediaCount(): Int { + return 2 + } + + override fun getContributionStateAt(position: Int): Int? { + return null + } + + override fun refreshNominatedMedia(index: Int) { + if (this::mediaDetails.isInitialized && !binding?.map?.isClickable!! == true) { + removeFragment(mediaDetails) + showMediaDetails() + } + } + + private fun removeFragment(fragment: Fragment) { + childFragmentManager + .beginTransaction() + .remove(fragment) + .commit() + childFragmentManager.executePendingTransactions() + } + private fun storeSharedPrefs(selectedPlace: Place) { applicationKvStore!!.putJson(WikidataConstants.PLACE_OBJECT, selectedPlace) val place = diff --git a/app/src/main/res/anim/rotate.xml b/app/src/main/res/anim/rotate.xml new file mode 100644 index 000000000..8c42dc3e5 --- /dev/null +++ b/app/src/main/res/anim/rotate.xml @@ -0,0 +1,10 @@ + + \ No newline at end of file diff --git a/app/src/main/res/drawable/loading_icon.xml b/app/src/main/res/drawable/loading_icon.xml new file mode 100644 index 000000000..babc0da6f --- /dev/null +++ b/app/src/main/res/drawable/loading_icon.xml @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file