Merge branch 'main' into explore_migration_kotlin

This commit is contained in:
Nicolas Raoul 2025-02-23 22:44:30 +09:00 committed by GitHub
commit f29e29fd13
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 409 additions and 71 deletions

View file

@ -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}`);
}

View file

@ -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

View file

View file

@ -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"

View file

@ -90,6 +90,41 @@ class Media constructor(
captions = captions,
)
constructor(
captions: Map<String, String>,
categories: List<String>?,
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<String, String> = emptyMap(),
depictionIds: List<String> = emptyList(),
categoriesHiddenStatus: Map<String, Boolean> = 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

View file

@ -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

View file

@ -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");
}
}

View file

@ -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<Drawable> {
override fun onLoadFailed(
e: GlideException?,
model: Any?,
target: Target<Drawable>,
isFirstResource: Boolean
): Boolean {
binding!!.bottomSheetDetails.icon.clearAnimation()
return false
}
override fun onResourceReady(
resource: Drawable,
model: Any,
target: Target<Drawable>?,
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<Place>(WikidataConstants.PLACE_OBJECT, selectedPlace)
val place =

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<rotate
xmlns:android="http://schemas.android.com/apk/res/android"
android:fromDegrees="0"
android:toDegrees="360"
android:pivotX="50%"
android:pivotY="50%"
android:duration="500"
android:repeatCount="infinite"
android:interpolator="@android:anim/linear_interpolator"/>

View file

@ -0,0 +1,49 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<vector
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M12,4 A8,8 0 1,1 4,12 A8,8 0 1,5 19.42 ,15"
android:strokeWidth="2"
android:strokeColor="#2196F3"
android:fillColor="#00000000"
android:trimPathStart="0.2"
android:trimPathEnd="1.0"/>
</vector>
</item>
<item>
<vector
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M12,4 A8,8 0 1,1 4,12 A8,8 0 1,1 12,4"
android:strokeWidth="2"
android:strokeColor="#FFFFFF"
android:fillColor="#00000000"
android:trimPathStart="0.0"
android:trimPathEnd="0.2"/>
</vector>
</item>
<item android:right="12dp">
<rotate
android:fromDegrees="0"
android:toDegrees="360"
android:pivotX="50%"
android:pivotY="50%">
<shape android:shape="ring">
<size
android:width="12dp"
android:height="2dp"/>
<solid android:color="#2196F3"/>
</shape>
</rotate>
</item>
</layer-list>