···11+diff --git a/ios/FontLoaderModule.swift b/ios/FontLoaderModule.swift
22+index 183480f..7b64f6e 100644
33+--- a/ios/FontLoaderModule.swift
44++++ b/ios/FontLoaderModule.swift
55+@@ -2,10 +2,9 @@ import ExpoModulesCore
66+77+ public final class FontLoaderModule: Module {
88+ // could be a Set, but to be able to pass to JS we keep it as an array
99+- private var registeredFonts: [String]
1010++ private lazy var registeredFonts: [String] = queryCustomNativeFonts()
1111+1212+ public required init(appContext: AppContext) {
1313+- self.registeredFonts = queryCustomNativeFonts()
1414+ super.init(appContext: appContext)
1515+ }
1616+
+60
patches/expo-glass-effect.patch
···11+diff --git a/ios/GlassContainer.swift b/ios/GlassContainer.swift
22+index 61fb67c..b2d111e 100644
33+--- a/ios/GlassContainer.swift
44++++ b/ios/GlassContainer.swift
55+@@ -1,6 +1,7 @@
66+ // Copyright 2022-present 650 Industries. All rights reserved.
77+88+ import ExpoModulesCore
99++import React
1010+1111+ public final class GlassContainer: ExpoView {
1212+ private var containerEffect: Any?
1313+@@ -46,11 +47,19 @@ public final class GlassContainer: ExpoView {
1414+ }
1515+ }
1616+1717+- public override func mountChildComponentView(_ childComponentView: UIView, index: Int) {
1818++ // Paper: redirect children into the container effect's contentView
1919++ public override func didUpdateReactSubviews() {
2020++ for subview in self.reactSubviews() {
2121++ containerEffectView.contentView.addSubview(subview)
2222++ }
2323++ }
2424++
2525++ // Fabric: redirect children into the container effect's contentView
2626++ @objc public func mountChildComponentView(_ childComponentView: UIView, index: Int) {
2727+ containerEffectView.contentView.insertSubview(childComponentView, at: index)
2828+ }
2929+3030+- public override func unmountChildComponentView(_ childComponentView: UIView, index: Int) {
3131++ @objc public func unmountChildComponentView(_ childComponentView: UIView, index: Int) {
3232+ childComponentView.removeFromSuperview()
3333+ }
3434+ }
3535+diff --git a/ios/GlassView.swift b/ios/GlassView.swift
3636+index 35cd8f3..9587306 100644
3737+--- a/ios/GlassView.swift
3838++++ b/ios/GlassView.swift
3939+@@ -271,11 +271,19 @@ public final class GlassView: ExpoView {
4040+ #endif
4141+ }
4242+ }
4343+- public override func mountChildComponentView(_ childComponentView: UIView, index: Int) {
4444++ // Paper: redirect children into the glass effect's contentView
4545++ public override func didUpdateReactSubviews() {
4646++ for subview in self.reactSubviews() {
4747++ glassEffectView.contentView.addSubview(subview)
4848++ }
4949++ }
5050++
5151++ // Fabric: redirect children into the glass effect's contentView
5252++ @objc public func mountChildComponentView(_ childComponentView: UIView, index: Int) {
5353+ glassEffectView.contentView.insertSubview(childComponentView, at: index)
5454+ }
5555+5656+- public override func unmountChildComponentView(_ childComponentView: UIView, index: Int) {
5757++ @objc public func unmountChildComponentView(_ childComponentView: UIView, index: Int) {
5858+ childComponentView.removeFromSuperview()
5959+ }
6060+ }
···11+diff --git a/android/src/main/java/expo/modules/kotlin/devtools/ExpoNetworkInspectOkHttpInterceptors.kt b/android/src/main/java/expo/modules/kotlin/devtools/ExpoNetworkInspectOkHttpInterceptors.kt
22+index 47c4d15..afe138d 100644
33+--- a/android/src/main/java/expo/modules/kotlin/devtools/ExpoNetworkInspectOkHttpInterceptors.kt
44++++ b/android/src/main/java/expo/modules/kotlin/devtools/ExpoNetworkInspectOkHttpInterceptors.kt
55+@@ -125,6 +125,10 @@ internal fun peekResponseBody(
66+ }
77+88+ internal fun shouldParseBody(response: Response): Boolean {
99++ if (response.request.url.encodedPath == "/bitdrift_public.protobuf.client.v1.ApiService/Mux") {
1010++ return false
1111++ }
1212++
1313+ // Check for Content-Type
1414+ val skipContentTypes = listOf(
1515+ "text/event-stream", // Server Sent Events
+170
patches/expo-notifications.patch
···11+diff --git a/android/build.gradle b/android/build.gradle
22+index bc479ee..1ebfa00 100644
33+--- a/android/build.gradle
44++++ b/android/build.gradle
55+@@ -42,6 +42,7 @@ dependencies {
66+ implementation 'com.google.firebase:firebase-messaging:24.0.1'
77+88+ implementation 'me.leolin:ShortcutBadger:1.1.22@aar'
99++ implementation project(':expo-background-notification-handler')
1010+1111+ if (project.findProject(':expo-modules-test-core')) {
1212+ testImplementation project(':expo-modules-test-core')
1313+diff --git a/android/src/main/java/expo/modules/notifications/notifications/interfaces/INotificationContent.kt b/android/src/main/java/expo/modules/notifications/notifications/interfaces/INotificationContent.kt
1414+index 7b99e6c..45a450d 100644
1515+--- a/android/src/main/java/expo/modules/notifications/notifications/interfaces/INotificationContent.kt
1616++++ b/android/src/main/java/expo/modules/notifications/notifications/interfaces/INotificationContent.kt
1717+@@ -15,6 +15,7 @@ import org.json.JSONObject
1818+ * This interface exists to provide a common API for both classes.
1919+ * */
2020+ interface INotificationContent : Parcelable {
2121++ val channelId: String?
2222+ val title: String?
2323+ val text: String?
2424+ val subText: String?
2525+diff --git a/android/src/main/java/expo/modules/notifications/notifications/model/NotificationContent.java b/android/src/main/java/expo/modules/notifications/notifications/model/NotificationContent.java
2626+index 191b64e..fe8b3c5 100644
2727+--- a/android/src/main/java/expo/modules/notifications/notifications/model/NotificationContent.java
2828++++ b/android/src/main/java/expo/modules/notifications/notifications/model/NotificationContent.java
2929+@@ -35,6 +35,7 @@ import kotlin.coroutines.Continuation;
3030+ * Refactoring this class may require a migration strategy for the data stored in SharedPreferences.
3131+ */
3232+ public class NotificationContent implements Parcelable, Serializable, INotificationContent {
3333++ private String mChannelId;
3434+ private String mTitle;
3535+ private String mText;
3636+ private String mSubtitle;
3737+@@ -65,6 +66,11 @@ public class NotificationContent implements Parcelable, Serializable, INotificat
3838+ }
3939+ };
4040+4141++ @Nullable
4242++ public String getChannelId() {
4343++ return mChannelId;
4444++ }
4545++
4646+ @Nullable
4747+ public String getTitle() {
4848+ return mTitle;
4949+@@ -158,6 +164,7 @@ public class NotificationContent implements Parcelable, Serializable, INotificat
5050+ }
5151+5252+ protected NotificationContent(Parcel in) {
5353++ mChannelId = in.readString();
5454+ mTitle = in.readString();
5555+ mText = in.readString();
5656+ mSubtitle = in.readString();
5757+@@ -183,6 +190,7 @@ public class NotificationContent implements Parcelable, Serializable, INotificat
5858+5959+ @Override
6060+ public void writeToParcel(Parcel dest, int flags) {
6161++ dest.writeString(mChannelId);
6262+ dest.writeString(mTitle);
6363+ dest.writeString(mText);
6464+ dest.writeString(mSubtitle);
6565+@@ -203,6 +211,7 @@ public class NotificationContent implements Parcelable, Serializable, INotificat
6666+ private static final long serialVersionUID = 397666843266836802L;
6767+6868+ private void writeObject(java.io.ObjectOutputStream out) throws IOException {
6969++ out.writeObject(mChannelId);
7070+ out.writeObject(mTitle);
7171+ out.writeObject(mText);
7272+ out.writeObject(mSubtitle);
7373+@@ -285,6 +294,11 @@ public class NotificationContent implements Parcelable, Serializable, INotificat
7474+ useDefaultVibrationPattern();
7575+ }
7676+7777++ public Builder setChannelId(String channelId) {
7878++ content.mChannelId = channelId;
7979++ return this;
8080++ }
8181++
8282+ public Builder setTitle(String title) {
8383+ content.mTitle = title;
8484+ return this;
8585+diff --git a/android/src/main/java/expo/modules/notifications/notifications/model/NotificationData.kt b/android/src/main/java/expo/modules/notifications/notifications/model/NotificationData.kt
8686+index 3af254c..3c77e9d 100644
8787+--- a/android/src/main/java/expo/modules/notifications/notifications/model/NotificationData.kt
8888++++ b/android/src/main/java/expo/modules/notifications/notifications/model/NotificationData.kt
8989+@@ -11,6 +11,9 @@ import org.json.JSONObject
9090+ * */
9191+ @JvmInline
9292+ value class NotificationData(private val data: Map<String, String>) {
9393++ val channelId: String?
9494++ get() = data["channelId"]
9595++
9696+ val title: String?
9797+ get() = data["title"]
9898+9999+diff --git a/android/src/main/java/expo/modules/notifications/notifications/model/RemoteNotificationContent.kt b/android/src/main/java/expo/modules/notifications/notifications/model/RemoteNotificationContent.kt
100100+index d2cc6cf..6a48ff2 100644
101101+--- a/android/src/main/java/expo/modules/notifications/notifications/model/RemoteNotificationContent.kt
102102++++ b/android/src/main/java/expo/modules/notifications/notifications/model/RemoteNotificationContent.kt
103103+@@ -31,6 +31,8 @@ class RemoteNotificationContent(private val remoteMessage: RemoteMessage) : INot
104104+ return remoteMessage.notification?.imageUrl != null
105105+ }
106106+107107++ override val channelId = remoteMessage.notification?.channelId ?: notificationData.channelId
108108++
109109+ override val title = remoteMessage.notification?.title ?: notificationData.title
110110+111111+ override val text = remoteMessage.notification?.body ?: notificationData.message
112112+diff --git a/android/src/main/java/expo/modules/notifications/notifications/presentation/builders/ExpoNotificationBuilder.kt b/android/src/main/java/expo/modules/notifications/notifications/presentation/builders/ExpoNotificationBuilder.kt
113113+index 98f003f..2f745e8 100644
114114+--- a/android/src/main/java/expo/modules/notifications/notifications/presentation/builders/ExpoNotificationBuilder.kt
115115++++ b/android/src/main/java/expo/modules/notifications/notifications/presentation/builders/ExpoNotificationBuilder.kt
116116+@@ -101,6 +101,9 @@ open class ExpoNotificationBuilder(
117117+ builder.setOngoing(content.isSticky)
118118+119119+ // see "Notification anatomy" https://developer.android.com/develop/ui/views/notifications#Templates
120120++ content.channelId?.let {
121121++ builder.setChannelId(it)
122122++ }
123123+ builder.setContentTitle(content.title)
124124+ builder.setContentText(content.text)
125125+ builder.setSubText(content.subText)
126126+diff --git a/android/src/main/java/expo/modules/notifications/service/delegates/FirebaseMessagingDelegate.kt b/android/src/main/java/expo/modules/notifications/service/delegates/FirebaseMessagingDelegate.kt
127127+index 90ca4ff..9d4cb09 100644
128128+--- a/android/src/main/java/expo/modules/notifications/service/delegates/FirebaseMessagingDelegate.kt
129129++++ b/android/src/main/java/expo/modules/notifications/service/delegates/FirebaseMessagingDelegate.kt
130130+@@ -3,6 +3,9 @@ package expo.modules.notifications.service.delegates
131131+ import android.content.Context
132132+ import android.os.Bundle
133133+ import com.google.firebase.messaging.RemoteMessage
134134++import expo.modules.backgroundnotificationhandler.BackgroundNotificationHandler
135135++import expo.modules.backgroundnotificationhandler.BackgroundNotificationHandlerInterface
136136++import expo.modules.backgroundnotificationhandler.ExpoBackgroundNotificationHandlerModule
137137+ import expo.modules.interfaces.taskManager.TaskServiceProviderHelper
138138+ import expo.modules.notifications.notifications.RemoteMessageSerializer
139139+ import expo.modules.notifications.notifications.background.BackgroundRemoteNotificationTaskConsumer
140140+@@ -18,7 +21,7 @@ import expo.modules.notifications.tokens.interfaces.FirebaseTokenListener
141141+ import java.lang.ref.WeakReference
142142+ import java.util.*
143143+144144+-open class FirebaseMessagingDelegate(protected val context: Context) : FirebaseMessagingDelegate {
145145++open class FirebaseMessagingDelegate(protected val context: Context) : FirebaseMessagingDelegate, BackgroundNotificationHandlerInterface{
146146+ companion object {
147147+ // Unfortunately we cannot save state between instances of a service other way
148148+ // than by static properties. Fortunately, using weak references we can
149149+@@ -105,8 +108,19 @@ open class FirebaseMessagingDelegate(protected val context: Context) : FirebaseM
150150+ DebugLogging.logRemoteMessage("FirebaseMessagingDelegate.onMessageReceived: message", remoteMessage)
151151+ val notification = createNotification(remoteMessage)
152152+ DebugLogging.logNotification("FirebaseMessagingDelegate.onMessageReceived: notification", notification)
153153+- NotificationsService.receive(context, notification)
154154+- runTaskManagerTasks(context.applicationContext, RemoteMessageSerializer.toBundle(remoteMessage))
155155++ if (!ExpoBackgroundNotificationHandlerModule.isForegrounded) {
156156++ BackgroundNotificationHandler(context, this).handleMessage(remoteMessage)
157157++ } else {
158158++ NotificationsService.receive(context, notification)
159159++ runTaskManagerTasks(
160160++ context.applicationContext,
161161++ RemoteMessageSerializer.toBundle(remoteMessage)
162162++ )
163163++ }
164164++ }
165165++
166166++ override fun showMessage(remoteMessage: RemoteMessage) {
167167++ NotificationsService.receive(context, createNotification(remoteMessage))
168168+ }
169169+170170+ protected fun createNotification(remoteMessage: RemoteMessage): Notification {
+136
patches/expo-paste-input.patch
···11+diff --git a/ios/ExpoPasteInputView.swift b/ios/ExpoPasteInputView.swift
22+index 2164aec4ec1d..d216db6d2927 100644
33+--- a/ios/ExpoPasteInputView.swift
44++++ b/ios/ExpoPasteInputView.swift
55+@@ -511,14 +511,17 @@ class ExpoPasteInputView: ExpoView {
66+ var attachmentRanges: [NSRange] = []
77+ var mediaPayloads: [MediaPayload] = []
88+99++ // Only track ranges for attachments we successfully extract a real payload
1010++ // from. Attachments without a payload (e.g. iOS dictation placeholders)
1111++ // are left alone — sanitizing them would delete characters the system
1212++ // manages itself, and emitting "unsupported" would raise a spurious error.
1313+ attributedText.enumerateAttribute(.attachment, in: NSRange(location: 0, length: attributedText.length), options: []) { value, range, _ in
1414+ guard let attachment = value as? NSTextAttachment else {
1515+ return
1616+ }
1717+1818+- attachmentRanges.append(range)
1919+-
2020+ if let payload = self.extractMediaPayload(from: attachment, textView: textView, range: range) {
2121++ attachmentRanges.append(range)
2222+ mediaPayloads.append(payload)
2323+ }
2424+ }
2525+@@ -529,9 +532,8 @@ class ExpoPasteInputView: ExpoView {
2626+ return
2727+ }
2828+2929+- attachmentRanges.append(range)
3030+-
3131+ if let payload = self.extractMediaPayload(from: adaptiveGlyph) {
3232++ attachmentRanges.append(range)
3333+ mediaPayloads.append(payload)
3434+ }
3535+ }
3636+@@ -539,17 +541,12 @@ class ExpoPasteInputView: ExpoView {
3737+3838+ attachmentRanges = uniqueRanges(attachmentRanges)
3939+4040+- guard !attachmentRanges.isEmpty else {
4141+- return
4242+- }
4343+-
4444+- sanitizeAttachments(in: textView, ranges: attachmentRanges)
4545+-
4646+ guard !mediaPayloads.isEmpty else {
4747+- handleUnsupportedPaste()
4848+ return
4949+ }
5050+5151++ sanitizeAttachments(in: textView, ranges: attachmentRanges)
5252++
5353+ emitImagesAsync(for: mediaPayloads)
5454+ }
5555+5656+@@ -651,6 +648,11 @@ class ExpoPasteInputView: ExpoView {
5757+ }
5858+5959+ private func extractMediaPayload(from attachment: NSTextAttachment, textView: UITextView, range: NSRange) -> MediaPayload? {
6060++ // Only accept attachments that carry real image payloads. We intentionally
6161++ // do not fall back to `image(forBounds:)` or rendering the text view's
6262++ // hierarchy, because system-inserted attachments (e.g. the iOS dictation
6363++ // placeholder) draw themselves via those paths and would cause us to
6464++ // emit a screenshot of the composer as a "pasted image".
6565+ if let fileWrapperData = attachment.fileWrapper?.regularFileContents,
6666+ let payload = extractMediaPayload(fromData: fileWrapperData) {
6767+ return payload
6868+@@ -667,20 +669,6 @@ class ExpoPasteInputView: ExpoView {
6969+ return .image(image)
7070+ }
7171+7272+- let attachmentBounds = attachment.bounds.size.width > 0 && attachment.bounds.size.height > 0
7373+- ? attachment.bounds
7474+- : CGRect(origin: .zero, size: CGSize(width: 128, height: 128))
7575+-
7676+- if let image = attachment.image(forBounds: attachmentBounds, textContainer: textView.textContainer, characterIndex: range.location),
7777+- image.size.width > 0,
7878+- image.size.height > 0 {
7979+- return .image(image)
8080+- }
8181+-
8282+- if let renderedImage = renderTextAttachment(in: textView, range: range) {
8383+- return .image(renderedImage)
8484+- }
8585+-
8686+ return nil
8787+ }
8888+8989+@@ -701,47 +689,6 @@ class ExpoPasteInputView: ExpoView {
9090+ return .imageData(data)
9191+ }
9292+9393+- private func renderTextAttachment(in textView: UITextView, range: NSRange) -> UIImage? {
9494+- let glyphRange = textView.layoutManager.glyphRange(forCharacterRange: range, actualCharacterRange: nil)
9595+- var rect = textView.layoutManager.boundingRect(forGlyphRange: glyphRange, in: textView.textContainer)
9696+-
9797+- rect.origin.x += textView.textContainerInset.left - textView.contentOffset.x
9898+- rect.origin.y += textView.textContainerInset.top - textView.contentOffset.y
9999+- rect = rect.integral
100100+-
101101+- guard rect.width > 1, rect.height > 1 else {
102102+- return nil
103103+- }
104104+-
105105+- let format = UIGraphicsImageRendererFormat.default()
106106+- format.scale = textView.window?.screen.scale ?? UIScreen.main.scale
107107+- format.opaque = false
108108+-
109109+- let image = UIGraphicsImageRenderer(size: rect.size, format: format).image { _ in
110110+- let drawRect = CGRect(
111111+- origin: CGPoint(x: -rect.origin.x, y: -rect.origin.y),
112112+- size: textView.bounds.size
113113+- )
114114+-
115115+- if textView.window != nil {
116116+- textView.drawHierarchy(in: drawRect, afterScreenUpdates: false)
117117+- } else {
118118+- guard let context = UIGraphicsGetCurrentContext() else {
119119+- return
120120+- }
121121+-
122122+- context.translateBy(x: -rect.origin.x, y: -rect.origin.y)
123123+- textView.layer.render(in: context)
124124+- }
125125+- }
126126+-
127127+- guard image.size.width > 0, image.size.height > 0 else {
128128+- return nil
129129+- }
130130+-
131131+- return image
132132+- }
133133+-
134134+ @available(iOS 18.0, *)
135135+ private func handleAdaptiveImageGlyphInsertion(_ adaptiveGlyph: NSAdaptiveImageGlyph) -> Bool {
136136+ guard let payload = extractMediaPayload(from: adaptiveGlyph) else {
+26
patches/expo-updates.patch
···11+diff --git a/ios/EXUpdates/Update/ExpoUpdatesUpdate.swift b/ios/EXUpdates/Update/ExpoUpdatesUpdate.swift
22+index 68086bd..78c7761 100644
33+--- a/ios/EXUpdates/Update/ExpoUpdatesUpdate.swift
44++++ b/ios/EXUpdates/Update/ExpoUpdatesUpdate.swift
55+@@ -78,13 +78,20 @@ public final class ExpoUpdatesUpdate: Update {
66+ status = UpdateStatus.StatusPending
77+ }
88+99++ // Instead of relying on various hacks to get the correct format for the specific
1010++ // platform on the backend, we can just add this little patch..
1111++ let dateFormatter = DateFormatter()
1212++ dateFormatter.locale = Locale(identifier: "en_US_POSIX")
1313++ dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZ"
1414++ let date = dateFormatter.date(from:commitTime) ?? RCTConvert.nsDate(commitTime)!
1515++
1616+ return Update(
1717+ manifest: manifest,
1818+ config: config,
1919+ database: database,
2020+ updateId: uuid,
2121+ scopeKey: config.scopeKey,
2222+- commitTime: RCTConvert.nsDate(commitTime),
2323++ commitTime: date,
2424+ runtimeVersion: runtimeVersion,
2525+ keep: true,
2626+ status: status,
+516
patches/react-native-compressor.patch
···11+diff --git a/android/build.gradle b/android/build.gradle
22+index 5071139..84bee34 100644
33+--- a/android/build.gradle
44++++ b/android/build.gradle
55+@@ -115,7 +115,6 @@ dependencies {
66+ implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4"
77+ implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4"
88+ implementation 'org.mp4parser:isoparser:1.9.56'
99+- implementation 'com.github.banketree:AndroidLame-kotlin:v0.0.1'
1010+ implementation 'javazoom:jlayer:1.0.1'
1111+ }
1212+1313+diff --git a/android/src/main/java/com/reactnativecompressor/Audio/AudioCompressor.kt b/android/src/main/java/com/reactnativecompressor/Audio/AudioCompressor.kt
1414+deleted file mode 100644
1515+index 9292d3e..0000000
1616+--- a/android/src/main/java/com/reactnativecompressor/Audio/AudioCompressor.kt
1717++++ /dev/null
1818+@@ -1,264 +0,0 @@
1919+-package com.reactnativecompressor.Audio
2020+-
2121+-
2222+-import android.annotation.SuppressLint
2323+-import com.facebook.react.bridge.Promise
2424+-import com.facebook.react.bridge.ReactApplicationContext
2525+-import com.facebook.react.bridge.ReadableMap
2626+-import com.naman14.androidlame.LameBuilder
2727+-import com.naman14.androidlame.WaveReader
2828+-import com.reactnativecompressor.Utils.MediaCache
2929+-import com.reactnativecompressor.Utils.Utils
3030+-import com.reactnativecompressor.Utils.Utils.addLog
3131+-import javazoom.jl.converter.Converter
3232+-import javazoom.jl.decoder.JavaLayerException
3333+-import java.io.BufferedOutputStream
3434+-import java.io.File
3535+-import java.io.FileNotFoundException
3636+-import java.io.FileOutputStream
3737+-import java.io.IOException
3838+-
3939+-class AudioCompressor {
4040+- companion object {
4141+- val TAG="AudioMain"
4242+- private const val OUTPUT_STREAM_BUFFER = 8192
4343+-
4444+- var outputStream: BufferedOutputStream? = null
4545+- var waveReader: WaveReader? = null
4646+- @JvmStatic
4747+- fun CompressAudio(
4848+- fileUrl: String,
4949+- optionMap: ReadableMap,
5050+- context: ReactApplicationContext,
5151+- promise: Promise,
5252+- ) {
5353+- val realPath = Utils.getRealPath(fileUrl, context)
5454+- var _fileUrl=realPath
5555+- val filePathWithoutFileUri = realPath!!.replace("file://", "")
5656+- try {
5757+- var wavPath=filePathWithoutFileUri;
5858+- var isNonWav:Boolean=false
5959+- if (fileUrl.endsWith(".mp4", ignoreCase = true))
6060+- {
6161+- addLog("mp4 file found")
6262+- val mp3Path= Utils.generateCacheFilePath("mp3", context)
6363+- AudioExtractor().genVideoUsingMuxer(fileUrl, mp3Path, -1, -1, true, false)
6464+- _fileUrl=Utils.slashifyFilePath(mp3Path)
6565+- wavPath= Utils.generateCacheFilePath("wav", context)
6666+- try {
6767+- val converter = Converter()
6868+- converter.convert(mp3Path, wavPath)
6969+- } catch (e: JavaLayerException) {
7070+- addLog("JavaLayerException error"+e.localizedMessage)
7171+- e.printStackTrace();
7272+- }
7373+- isNonWav=true
7474+- }
7575+- else if (!fileUrl.endsWith(".wav", ignoreCase = true))
7676+- {
7777+- addLog("non wav file found")
7878+- wavPath= Utils.generateCacheFilePath("wav", context)
7979+- try {
8080+- val converter = Converter()
8181+- converter.convert(filePathWithoutFileUri, wavPath)
8282+- } catch (e: JavaLayerException) {
8383+- addLog("JavaLayerException error"+e.localizedMessage)
8484+- e.printStackTrace();
8585+- }
8686+- isNonWav=true
8787+- }
8888+-
8989+-
9090+- autoCompressHelper(wavPath,filePathWithoutFileUri, optionMap,context) { mp3Path, finished ->
9191+- if (finished) {
9292+- val returnableFilePath:String="file://$mp3Path"
9393+- addLog("finished: " + returnableFilePath)
9494+- MediaCache.removeCompletedImagePath(fileUrl)
9595+- if(isNonWav)
9696+- {
9797+- File(wavPath).delete()
9898+- }
9999+- promise.resolve(returnableFilePath)
100100+- } else {
101101+- addLog("error: "+mp3Path)
102102+- promise.resolve(_fileUrl)
103103+- }
104104+- }
105105+- } catch (e: Exception) {
106106+- promise.resolve(_fileUrl)
107107+- }
108108+- }
109109+-
110110+- @SuppressLint("WrongConstant")
111111+- private fun autoCompressHelper(
112112+- fileUrl: String,
113113+- actualFileUrl: String,
114114+- optionMap: ReadableMap,
115115+- context: ReactApplicationContext,
116116+- completeCallback: (String, Boolean) -> Unit
117117+- ) {
118118+-
119119+- val options = AudioHelper.fromMap(optionMap)
120120+- val quality = options.quality
121121+-
122122+- var isCompletedCallbackTriggered:Boolean=false
123123+- try {
124124+- var mp3Path = Utils.generateCacheFilePath("mp3", context)
125125+- val input = File(fileUrl)
126126+- val output = File(mp3Path)
127127+-
128128+- val CHUNK_SIZE = 8192
129129+- addLog("Initialising wav reader")
130130+-
131131+- waveReader = WaveReader(input)
132132+-
133133+- try {
134134+- waveReader!!.openWave()
135135+- } catch (e: IOException) {
136136+- e.printStackTrace()
137137+- }
138138+-
139139+- addLog("Intitialising encoder")
140140+-
141141+-
142142+- // for bitrate
143143+- var audioBitrate:Int
144144+- if(options.bitrate != -1)
145145+- {
146146+- audioBitrate= options.bitrate/1000
147147+- }
148148+- else
149149+- {
150150+- audioBitrate=AudioHelper.getDestinationBitrateByQuality(actualFileUrl, quality!!)
151151+- Utils.addLog("dest bitrate: $audioBitrate")
152152+- }
153153+-
154154+- var androidLame = LameBuilder();
155155+- androidLame.setOutBitrate(audioBitrate)
156156+-
157157+- // for channels
158158+- var audioChannels:Int
159159+- if(options.channels != -1){
160160+- audioChannels= options.channels!!
161161+- }
162162+- else
163163+- {
164164+- audioChannels=waveReader!!.channels
165165+- }
166166+- androidLame.setOutChannels(audioChannels)
167167+-
168168+- // for sample rate
169169+- androidLame.setInSampleRate(waveReader!!.sampleRate)
170170+- var audioSampleRate:Int
171171+- if(options.samplerate != -1){
172172+- audioSampleRate= options.samplerate!!
173173+- }
174174+- else
175175+- {
176176+- audioSampleRate=waveReader!!.sampleRate
177177+- }
178178+- androidLame.setOutSampleRate(audioSampleRate)
179179+- val androidLameBuild=androidLame.build()
180180+-
181181+- try {
182182+- outputStream = BufferedOutputStream(FileOutputStream(output), OUTPUT_STREAM_BUFFER)
183183+- } catch (e: FileNotFoundException) {
184184+- e.printStackTrace()
185185+- }
186186+-
187187+- var bytesRead = 0
188188+-
189189+- val buffer_l = ShortArray(CHUNK_SIZE)
190190+- val buffer_r = ShortArray(CHUNK_SIZE)
191191+- val mp3Buf = ByteArray(CHUNK_SIZE)
192192+-
193193+- val channels = waveReader!!.channels
194194+-
195195+- addLog("started encoding")
196196+- while (true) {
197197+- try {
198198+- if (channels == 2) {
199199+-
200200+- bytesRead = waveReader!!.read(buffer_l, buffer_r, CHUNK_SIZE)
201201+- addLog("bytes read=$bytesRead")
202202+-
203203+- if (bytesRead > 0) {
204204+-
205205+- var bytesEncoded = 0
206206+- bytesEncoded = androidLameBuild.encode(buffer_l, buffer_r, bytesRead, mp3Buf)
207207+- addLog("bytes encoded=$bytesEncoded")
208208+-
209209+- if (bytesEncoded > 0) {
210210+- try {
211211+- addLog("writing mp3 buffer to outputstream with $bytesEncoded bytes")
212212+- outputStream!!.write(mp3Buf, 0, bytesEncoded)
213213+- } catch (e: IOException) {
214214+- e.printStackTrace()
215215+- }
216216+-
217217+- }
218218+-
219219+- } else
220220+- break
221221+- } else {
222222+-
223223+- bytesRead = waveReader!!.read(buffer_l, CHUNK_SIZE)
224224+- addLog("bytes read=$bytesRead")
225225+-
226226+- if (bytesRead > 0) {
227227+- var bytesEncoded = 0
228228+-
229229+- bytesEncoded = androidLameBuild.encode(buffer_l, buffer_l, bytesRead, mp3Buf)
230230+- addLog("bytes encoded=$bytesEncoded")
231231+-
232232+- if (bytesEncoded > 0) {
233233+- try {
234234+- addLog("writing mp3 buffer to outputstream with $bytesEncoded bytes")
235235+- outputStream!!.write(mp3Buf, 0, bytesEncoded)
236236+- } catch (e: IOException) {
237237+- e.printStackTrace()
238238+- }
239239+-
240240+- }
241241+-
242242+- } else
243243+- break
244244+- }
245245+-
246246+-
247247+- } catch (e: IOException) {
248248+- e.printStackTrace()
249249+- }
250250+-
251251+- }
252252+-
253253+- addLog("flushing final mp3buffer")
254254+- val outputMp3buf = androidLameBuild.flush(mp3Buf)
255255+- addLog("flushed $outputMp3buf bytes")
256256+- if (outputMp3buf > 0) {
257257+- try {
258258+- addLog("writing final mp3buffer to outputstream")
259259+- outputStream!!.write(mp3Buf, 0, outputMp3buf)
260260+- addLog("closing output stream")
261261+- outputStream!!.close()
262262+- completeCallback(output.absolutePath, true)
263263+- isCompletedCallbackTriggered=true
264264+- } catch (e: IOException) {
265265+- completeCallback(e.localizedMessage, false)
266266+- e.printStackTrace()
267267+- }
268268+- }
269269+-
270270+- } catch (e: IOException) {
271271+- completeCallback(e.localizedMessage, false)
272272+- }
273273+- if(!isCompletedCallbackTriggered)
274274+- {
275275+- completeCallback("something went wrong", false)
276276+- }
277277+- }
278278+-
279279+-
280280+-
281281+- }
282282+-}
283283+diff --git a/android/src/main/java/com/reactnativecompressor/Audio/AudioExtractor.kt b/android/src/main/java/com/reactnativecompressor/Audio/AudioExtractor.kt
284284+deleted file mode 100644
285285+index c655182..0000000
286286+--- a/android/src/main/java/com/reactnativecompressor/Audio/AudioExtractor.kt
287287++++ /dev/null
288288+@@ -1,112 +0,0 @@
289289+-package com.reactnativecompressor.Audio
290290+-
291291+-import android.annotation.SuppressLint
292292+-import android.media.MediaCodec
293293+-import android.media.MediaExtractor
294294+-import android.media.MediaFormat
295295+-import android.media.MediaMetadataRetriever
296296+-import android.media.MediaMuxer
297297+-import android.util.Log
298298+-import java.io.IOException
299299+-import java.nio.ByteBuffer
300300+-
301301+-
302302+-class AudioExtractor {
303303+- /**
304304+- * @param srcPath the path of source video file.
305305+- * @param dstPath the path of destination video file.
306306+- * @param startMs starting time in milliseconds for trimming. Set to
307307+- * negative if starting from beginning.
308308+- * @param endMs end time for trimming in milliseconds. Set to negative if
309309+- * no trimming at the end.
310310+- * @param useAudio true if keep the audio track from the source.
311311+- * @param useVideo true if keep the video track from the source.
312312+- * @throws IOException
313313+- */
314314+- @SuppressLint("NewApi", "WrongConstant")
315315+- @Throws(IOException::class)
316316+- fun genVideoUsingMuxer(srcPath: String?, dstPath: String?, startMs: Int, endMs: Int, useAudio: Boolean, useVideo: Boolean) {
317317+- // Set up MediaExtractor to read from the source.
318318+- val extractor = MediaExtractor()
319319+- extractor.setDataSource(srcPath!!)
320320+- val trackCount = extractor.trackCount
321321+- // Set up MediaMuxer for the destination.
322322+- val muxer: MediaMuxer
323323+- muxer = MediaMuxer(dstPath!!, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4)
324324+- // Set up the tracks and retrieve the max buffer size for selected
325325+- // tracks.
326326+- val indexMap = HashMap<Int, Int>(trackCount)
327327+- var bufferSize = -1
328328+- for (i in 0 until trackCount) {
329329+- val format = extractor.getTrackFormat(i)
330330+- val mime = format.getString(MediaFormat.KEY_MIME)
331331+- var selectCurrentTrack = false
332332+- if (mime!!.startsWith("audio/") && useAudio) {
333333+- selectCurrentTrack = true
334334+- } else if (mime.startsWith("video/") && useVideo) {
335335+- selectCurrentTrack = true
336336+- }
337337+- if (selectCurrentTrack) {
338338+- extractor.selectTrack(i)
339339+- val dstIndex = muxer.addTrack(format)
340340+- indexMap[i] = dstIndex
341341+- if (format.containsKey(MediaFormat.KEY_MAX_INPUT_SIZE)) {
342342+- val newSize = format.getInteger(MediaFormat.KEY_MAX_INPUT_SIZE)
343343+- bufferSize = if (newSize > bufferSize) newSize else bufferSize
344344+- }
345345+- }
346346+- }
347347+- if (bufferSize < 0) {
348348+- bufferSize = DEFAULT_BUFFER_SIZE
349349+- }
350350+- // Set up the orientation and starting time for extractor.
351351+- val retrieverSrc = MediaMetadataRetriever()
352352+- retrieverSrc.setDataSource(srcPath)
353353+- val degreesString = retrieverSrc.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION)
354354+- if (degreesString != null) {
355355+- val degrees = degreesString.toInt()
356356+- if (degrees >= 0) {
357357+- muxer.setOrientationHint(degrees)
358358+- }
359359+- }
360360+- if (startMs > 0) {
361361+- extractor.seekTo((startMs * 1000).toLong(), MediaExtractor.SEEK_TO_CLOSEST_SYNC)
362362+- }
363363+- // Copy the samples from MediaExtractor to MediaMuxer. We will loop
364364+- // for copying each sample and stop when we get to the end of the source
365365+- // file or exceed the end time of the trimming.
366366+- val offset = 0
367367+- var trackIndex = -1
368368+- val dstBuf = ByteBuffer.allocate(bufferSize)
369369+- val bufferInfo = MediaCodec.BufferInfo()
370370+- muxer.start()
371371+- while (true) {
372372+- bufferInfo.offset = offset
373373+- bufferInfo.size = extractor.readSampleData(dstBuf, offset)
374374+- if (bufferInfo.size < 0) {
375375+- Log.d(TAG, "Saw input EOS.")
376376+- bufferInfo.size = 0
377377+- break
378378+- } else {
379379+- bufferInfo.presentationTimeUs = extractor.sampleTime
380380+- if (endMs > 0 && bufferInfo.presentationTimeUs > endMs * 1000) {
381381+- Log.d(TAG, "The current sample is over the trim end time.")
382382+- break
383383+- } else {
384384+- bufferInfo.flags = extractor.sampleFlags
385385+- trackIndex = extractor.sampleTrackIndex
386386+- muxer.writeSampleData(indexMap[trackIndex]!!, dstBuf, bufferInfo)
387387+- extractor.advance()
388388+- }
389389+- }
390390+- }
391391+- muxer.stop()
392392+- muxer.release()
393393+- return
394394+- }
395395+-
396396+- companion object {
397397+- private const val DEFAULT_BUFFER_SIZE = 1 * 1024 * 1024
398398+- private const val TAG = "AudioExtractorDecoder"
399399+- }
400400+-}
401401+diff --git a/android/src/main/java/com/reactnativecompressor/Audio/AudioHelper.kt b/android/src/main/java/com/reactnativecompressor/Audio/AudioHelper.kt
402402+deleted file mode 100644
403403+index 42040b4..0000000
404404+--- a/android/src/main/java/com/reactnativecompressor/Audio/AudioHelper.kt
405405++++ /dev/null
406406+@@ -1,72 +0,0 @@
407407+-package com.reactnativecompressor.Audio
408408+-
409409+-import android.media.MediaExtractor
410410+-import android.media.MediaFormat
411411+-import com.facebook.react.bridge.ReadableMap
412412+-import com.reactnativecompressor.Utils.Utils
413413+-import java.io.File
414414+-import java.io.IOException
415415+-
416416+-
417417+-class AudioHelper {
418418+-
419419+- var quality: String? = "medium"
420420+- var bitrate: Int = -1
421421+- var samplerate: Int = -1
422422+- var channels: Int = -1
423423+- var progressDivider: Int? = 0
424424+-
425425+- companion object {
426426+- fun fromMap(map: ReadableMap): AudioHelper {
427427+- val options = AudioHelper()
428428+- val iterator = map.keySetIterator()
429429+- while (iterator.hasNextKey()) {
430430+- val key = iterator.nextKey()
431431+- when (key) {
432432+- "quality" -> options.quality = map.getString(key)
433433+- "bitrate" -> {
434434+- val bitrate = map.getInt(key)
435435+- options.bitrate = if (bitrate > 320000 || bitrate < 64000) 64000 else bitrate
436436+- }
437437+- "samplerate" -> options.samplerate = map.getInt(key)
438438+- "channels" -> options.channels = map.getInt(key)
439439+- }
440440+- }
441441+- return options
442442+- }
443443+-
444444+-
445445+- fun getAudioBitrate(path: String): Int {
446446+- val file = File(path)
447447+- val fileSize = file.length() * 8 // size in bits
448448+-
449449+- val mex = MediaExtractor()
450450+- try {
451451+- mex.setDataSource(path)
452452+- } catch (e: IOException) {
453453+- e.printStackTrace()
454454+- }
455455+-
456456+- val mf = mex.getTrackFormat(0)
457457+- val durationUs = mf.getLong(MediaFormat.KEY_DURATION)
458458+- val durationSec = durationUs / 1_000_000.0 // convert duration to seconds
459459+-
460460+- return (fileSize / durationSec).toInt()/1000 // bitrate in bits per second
461461+- }
462462+- fun getDestinationBitrateByQuality(path: String, quality: String): Int {
463463+- val originalBitrate = getAudioBitrate(path)
464464+- var destinationBitrate = originalBitrate
465465+- Utils.addLog("source bitrate: $originalBitrate")
466466+-
467467+- when (quality.lowercase()) {
468468+- "low" -> destinationBitrate = maxOf(64, (originalBitrate * 0.3).toInt())
469469+- "medium" -> destinationBitrate = (originalBitrate * 0.5).toInt()
470470+- "high" -> destinationBitrate = minOf(320, (originalBitrate * 0.7).toInt())
471471+- else -> Utils.addLog("Invalid quality level. Please enter 'low', 'medium', or 'high'.")
472472+- }
473473+-
474474+- return destinationBitrate
475475+- }
476476+-
477477+- }
478478+-}
479479+diff --git a/android/src/main/java/com/reactnativecompressor/Audio/AudioMain.kt b/android/src/main/java/com/reactnativecompressor/Audio/AudioMain.kt
480480+index 446d4fb..f021909 100644
481481+--- a/android/src/main/java/com/reactnativecompressor/Audio/AudioMain.kt
482482++++ b/android/src/main/java/com/reactnativecompressor/Audio/AudioMain.kt
483483+@@ -11,7 +11,9 @@ class AudioMain(private val reactContext: ReactApplicationContext) {
484484+ promise: Promise) {
485485+ try {
486486+487487+- AudioCompressor.CompressAudio(fileUrl,optionMap,reactContext,promise)
488488++ // Skip compression on Android to avoid libandroidlame dependency
489489++ // Return the original file URL without compression
490490++ promise.resolve(fileUrl)
491491+ } catch (ex: Exception) {
492492+ promise.reject(ex)
493493+ }
494494+diff --git a/android/src/main/java/com/reactnativecompressor/Utils/Utils.kt b/android/src/main/java/com/reactnativecompressor/Utils/Utils.kt
495495+index c14b727..1198908 100644
496496+--- a/android/src/main/java/com/reactnativecompressor/Utils/Utils.kt
497497++++ b/android/src/main/java/com/reactnativecompressor/Utils/Utils.kt
498498+@@ -7,7 +7,6 @@ import android.provider.OpenableColumns
499499+ import android.util.Log
500500+ import com.facebook.react.bridge.Promise
501501+ import com.facebook.react.bridge.ReactApplicationContext
502502+-import com.reactnativecompressor.Audio.AudioCompressor
503503+ import com.reactnativecompressor.Video.VideoCompressor.CompressionListener
504504+ import com.reactnativecompressor.Video.VideoCompressor.VideoCompressorClass
505505+ import java.io.FileNotFoundException
506506+@@ -152,10 +151,6 @@ object Utils {
507507+ }
508508+ }
509509+510510+- fun addLog(log: String) {
511511+- Log.d(AudioCompressor.TAG, log)
512512+- }
513513+-
514514+ val exifAttributes = arrayOf(
515515+ "FNumber",
516516+ "ApertureValue",
···33## updateExtensions.sh
4455Updates the extensions in `/modules` with the current iOS/Android project changes.
66+77+## patch-package-to-pnpm.mjs
88+_(Witchsky-specific)_
99+1010+Run this periodically when the upstream patches change, to update the patch files in the `patches` directory. This is a
1111+custom script that generates patch files compatible with pnpm's patching system, which has some differences from the
1212+standard `patch-package` format. It uses git to create the patches and ensures they are correctly formatted for pnpm.
1313+1414+Then, update patchedDependencies in `package.json` to remove version specifiers from patches that don't need them
1515+(anything except @atproto/api, usually). That matches the `patch-package` behavior better.
+315
scripts/patch-package-to-pnpm.mjs
···11+#!/usr/bin/env -S node
22+/* eslint-disable import-x/no-nodejs-modules */
33+// @ts-check
44+55+// https://github.com/karlhorky/pnpm-tricks#convert-patch-package-patches-to-pnpm-patches
66+77+/**
88+ * Convert patch-package patches to pnpm-native patches by:
99+ *
1010+ * 1. Group all patch-package patches by package name
1111+ * 2. For non-overlapping patches: strip prefixes and
1212+ * concatenate
1313+ * 3. For overlapping patches (same file in multiple patches):
1414+ * download the original package, apply patches sequentially,
1515+ * and generate a squashed diff
1616+ * 4. Write a patch file: patches/<@scope__name>.patch
1717+ * 5. Update pnpm.patchedDependencies in package.json with a
1818+ * version-qualified key
1919+ *
2020+ * Original patch files not deleted, to allow for comparison.
2121+ */
2222+2323+import { execSync } from 'node:child_process'
2424+import { readFile, writeFile, cp, rm, mkdtemp, mkdir } from 'node:fs/promises'
2525+import os from 'node:os'
2626+import path from 'node:path'
2727+import util from 'node:util'
2828+2929+import glob from 'glob'
3030+3131+const globAsync = util.promisify(glob)
3232+3333+const rootDir = process.cwd()
3434+const patchesDir = path.join(rootDir, 'patches')
3535+const packageJsonPath = path.join(rootDir, 'package.json')
3636+3737+const patchPackagePatchPaths = (await globAsync('*+*.patch', { cwd: patchesDir }))
3838+ .map(
3939+ (file) => path.join(patchesDir, file),
4040+ )
4141+patchPackagePatchPaths.sort()
4242+4343+if (patchPackagePatchPaths.length === 0) {
4444+ console.log(`No patch-package patches found in ${patchesDir}.`)
4545+ process.exit(1)
4646+}
4747+4848+const packageJsonContent = /** @type {{ pnpm?: { patchedDependencies?: Record<string, string> } }} */ (JSON.parse(
4949+ await readFile(packageJsonPath, 'utf8'),
5050+))
5151+packageJsonContent.pnpm ??= {}
5252+packageJsonContent.pnpm.patchedDependencies ??= {}
5353+5454+/**
5555+ * Parse a patch-package filename to extract package info.
5656+ *
5757+ * Handles formats like:
5858+ * react-native+0.81.5.patch
5959+ * react-native+0.81.5+001+initial.patch
6060+ * @atproto+api+0.14.21.patch
6161+ * parent++child+1.0.0.patch
6262+ *
6363+ * @param {string} filePath
6464+ */
6565+function parsePatchFilename(filePath) {
6666+ const base = path.basename(filePath, '.patch')
6767+6868+ // Handle parent++leaf separation
6969+ let parentEncoded
7070+ let leafPart = base
7171+ const parentSepIndex = base.indexOf('++')
7272+ if (parentSepIndex !== -1) {
7373+ parentEncoded = base.slice(0, parentSepIndex)
7474+ leafPart = base.slice(parentSepIndex + 2)
7575+ }
7676+7777+ // Split by '+' and find the version (first segment starting with a digit)
7878+ const segments = leafPart.split('+')
7979+ let versionIndex = -1
8080+ for (let i = 1; i < segments.length; i++) {
8181+ if (/^\d/.test(segments[i])) {
8282+ versionIndex = i
8383+ break
8484+ }
8585+ }
8686+8787+ if (versionIndex < 1) {
8888+ return null
8989+ }
9090+9191+ const encodedLeafName = segments.slice(0, versionIndex).join('+')
9292+ // @scope+name -> @scope/name
9393+ const leafPackageName = encodedLeafName.replaceAll('+', '/')
9494+9595+ const parentPackageName = parentEncoded
9696+ ? parentEncoded.replaceAll('+', '/')
9797+ : undefined
9898+9999+ const nodeModulesPathPrefix = (
100100+ parentPackageName ? [parentPackageName, leafPackageName] : [leafPackageName]
101101+ )
102102+ .map((segment) => `node_modules/${segment}`)
103103+ .join('/')
104104+105105+ return {
106106+ leafPackageName,
107107+ encodedLeafName,
108108+ nodeModulesPathPrefix,
109109+ }
110110+}
111111+112112+// Group patches by leaf package name
113113+/** @type {Map<string, { encodedLeafName: string, nodeModulesPathPrefix: string, paths: string[] }>} */
114114+const patchGroups = new Map()
115115+116116+for (const patchPath of patchPackagePatchPaths) {
117117+ const parsed = parsePatchFilename(patchPath)
118118+ if (!parsed) {
119119+ console.error(
120120+ `Skipping ${patchPath}: cannot parse filename`,
121121+ )
122122+ continue
123123+ }
124124+125125+ if (!patchGroups.has(parsed.leafPackageName)) {
126126+ patchGroups.set(parsed.leafPackageName, { ...parsed, paths: [] })
127127+ }
128128+ patchGroups.get(parsed.leafPackageName).paths.push(patchPath)
129129+}
130130+131131+/**
132132+ * Extract the set of files modified by a patch.
133133+ * @param {string} patchContent
134134+ * @returns {Set<string>}
135135+ */
136136+function getModifiedFiles(patchContent) {
137137+ const files = new Set()
138138+ for (const match of patchContent.matchAll(/^diff --git a\/(.+?) b\//gm)) {
139139+ files.add(match[1])
140140+ }
141141+ return files
142142+}
143143+144144+/**
145145+ * Check if multiple patches modify any of the same files.
146146+ * @param {string[]} patchPaths
147147+ * @param {string} nodeModulesPathPrefix
148148+ * @returns {Promise<boolean>}
149149+ */
150150+async function hasOverlappingFiles(patchPaths, nodeModulesPathPrefix) {
151151+ const allFiles = new Set()
152152+ for (const patchPath of patchPaths) {
153153+ const content = await readFile(patchPath, 'utf8')
154154+ const stripped = content.replaceAll(`a/${nodeModulesPathPrefix}/`, 'a/')
155155+ for (const file of getModifiedFiles(stripped)) {
156156+ if (allFiles.has(file)) return true
157157+ allFiles.add(file)
158158+ }
159159+ }
160160+ return false
161161+}
162162+163163+for (const [leafPackageName, group] of patchGroups) {
164164+ const packageDir = path.join(rootDir, 'node_modules', ...leafPackageName.split('/'))
165165+166166+ // Read installed version for the version-qualified key
167167+ let installedVersion
168168+ try {
169169+ const installedPkgJson = JSON.parse(
170170+ await readFile(path.join(packageDir, 'package.json'), 'utf8'),
171171+ )
172172+ installedVersion = installedPkgJson.version
173173+ } catch {
174174+ console.warn(
175175+ `Could not resolve installed version for ${leafPackageName}`,
176176+ )
177177+ }
178178+179179+ const overlapping = group.paths.length > 1 &&
180180+ await hasOverlappingFiles(group.paths, group.nodeModulesPathPrefix)
181181+182182+ let pnpmPatchContent
183183+184184+ if (!overlapping) {
185185+ // Simple path: strip node_modules prefix and concatenate
186186+ const convertedParts = []
187187+ for (const patchPath of group.paths) {
188188+ const content = await readFile(patchPath, 'utf8')
189189+ const converted = content
190190+ .replaceAll(`a/${group.nodeModulesPathPrefix}/`, 'a/')
191191+ .replaceAll(`b/${group.nodeModulesPathPrefix}/`, 'b/')
192192+ convertedParts.push(converted)
193193+ }
194194+ pnpmPatchContent = convertedParts.join('')
195195+ } else {
196196+ // Overlapping patches: download original, apply sequentially, squash
197197+ if (!installedVersion) {
198198+ console.error(
199199+ `Cannot squash overlapping patches for ${leafPackageName}: unknown installed version`,
200200+ )
201201+ continue
202202+ }
203203+204204+ const pkgSegments = leafPackageName.split('/')
205205+ const stripLevel = 2 + pkgSegments.length
206206+207207+ const tmpDir = await mkdtemp(path.join(os.tmpdir(), 'pnpm-patch-'))
208208+ const originalDir = path.join(tmpDir, 'original')
209209+ const patchedDir = path.join(tmpDir, 'patched')
210210+211211+ try {
212212+ // Download original package tarball
213213+ console.log(`Downloading ${leafPackageName}@${installedVersion}...`)
214214+ execSync(
215215+ `npm pack "${leafPackageName}@${installedVersion}" --pack-destination "${tmpDir.replaceAll('\\', '/')}"`,
216216+ { stdio: 'pipe' },
217217+ )
218218+219219+ // Find the tarball (npm creates <scope-less-name>-<version>.tgz)
220220+ const tarballs = (await util.promisify(glob)('*.tgz', { cwd: tmpDir }))
221221+ if (tarballs.length === 0) {
222222+ throw new Error(`No tarball found after npm pack for ${leafPackageName}`)
223223+ }
224224+ const tarballPath = path.join(tmpDir, tarballs[0]).replaceAll('\\', '/')
225225+226226+ // Extract to both original and patched dirs
227227+ await mkdir(originalDir, { recursive: true })
228228+ await mkdir(patchedDir, { recursive: true })
229229+ execSync(
230230+ `tar xzf "${tarballPath}" -C "${originalDir.replaceAll('\\', '/')}" --strip-components=1`,
231231+ { stdio: 'pipe' },
232232+ )
233233+ execSync(
234234+ `tar xzf "${tarballPath}" -C "${patchedDir.replaceAll('\\', '/')}" --strip-components=1`,
235235+ { stdio: 'pipe' },
236236+ )
237237+238238+ // Apply each patch in sequence to the patched copy
239239+ for (const patchPath of group.paths) {
240240+ const gitPatchPath = patchPath.replaceAll('\\', '/')
241241+ try {
242242+ execSync(
243243+ `git apply -p${stripLevel} --ignore-whitespace "${gitPatchPath}"`,
244244+ { cwd: patchedDir, stdio: 'pipe' },
245245+ )
246246+ } catch (/** @type {any} */ e) {
247247+ const stderr = e.stderr?.toString() || ''
248248+ console.error(`Warning: patch may not have applied cleanly: ${path.basename(patchPath)}`)
249249+ console.error(stderr)
250250+ // Try with more lenient settings
251251+ execSync(
252252+ `git apply -p${stripLevel} --ignore-whitespace -C0 "${gitPatchPath}"`,
253253+ { cwd: patchedDir, stdio: 'pipe' },
254254+ )
255255+ }
256256+ }
257257+258258+ // Generate a single squashed diff
259259+ const gitOriginalDir = originalDir.replaceAll('\\', '/')
260260+ const gitPatchedDir = patchedDir.replaceAll('\\', '/')
261261+ let diff
262262+ try {
263263+ execSync(
264264+ `git diff --no-ext-diff --no-index -- "${gitOriginalDir}" "${gitPatchedDir}"`,
265265+ { encoding: 'utf8', stdio: 'pipe', maxBuffer: 50 * 1024 * 1024 },
266266+ )
267267+ console.log(`No differences found for ${leafPackageName}, skipping`)
268268+ continue
269269+ } catch (/** @type {any} */ e) {
270270+ if (e.status === 1) {
271271+ diff = /** @type {string} */ (e.stdout)
272272+ } else {
273273+ throw e
274274+ }
275275+ }
276276+277277+ // Replace temp dir paths with standard a/ b/ prefixes
278278+ pnpmPatchContent = diff
279279+ .replaceAll(`a/${gitOriginalDir}/`, 'a/')
280280+ .replaceAll(`b/${gitPatchedDir}/`, 'b/')
281281+ .replaceAll(`${gitOriginalDir}/`, '')
282282+ .replaceAll(`${gitPatchedDir}/`, '')
283283+ } finally {
284284+ await rm(tmpDir, { recursive: true, force: true })
285285+ }
286286+ }
287287+288288+ // @scope+name -> @scope__name
289289+ const pnpmPatchPath = path.join(
290290+ patchesDir,
291291+ `${group.encodedLeafName.replaceAll('+', '__')}.patch`,
292292+ )
293293+ await writeFile(pnpmPatchPath, pnpmPatchContent)
294294+295295+ const patchedDepKey = installedVersion
296296+ ? `${leafPackageName}@${installedVersion}`
297297+ : leafPackageName
298298+299299+ packageJsonContent.pnpm.patchedDependencies[patchedDepKey] = path
300300+ .relative(rootDir, pnpmPatchPath)
301301+ .replaceAll('\\', '/')
302302+303303+ const patchNames = group.paths.map((p) => path.relative(rootDir, p))
304304+ console.log(
305305+ `${overlapping ? 'Squashed' : 'Converted'} ${group.paths.length} patch(es) for ${leafPackageName}: ${patchNames.join(', ')}`,
306306+ )
307307+}
308308+309309+await writeFile(
310310+ packageJsonPath,
311311+ JSON.stringify(packageJsonContent, null, 2) + '\n',
312312+)
313313+console.log(
314314+ "All patches squashed to pnpm patches. Run 'pnpm install' to apply.",
315315+)