Skip to content

Commit 63321a8

Browse files
authored
Merge pull request #209 from Resgrid/develop
Develop
2 parents 4ebd2b4 + c0b2575 commit 63321a8

32 files changed

+1386
-547
lines changed

.DS_Store

0 Bytes
Binary file not shown.

app.config.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,8 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
8080
'android.permission.FOREGROUND_SERVICE_MICROPHONE',
8181
'android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE',
8282
'android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK',
83+
'android.permission.READ_PHONE_STATE',
84+
'android.permission.MANAGE_OWN_CALLS',
8385
],
8486
},
8587
web: {
@@ -208,6 +210,7 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
208210
'./plugins/withForegroundNotifications.js',
209211
'./plugins/withNotificationSounds.js',
210212
'./plugins/withMediaButtonModule.js',
213+
'./plugins/withInCallAudioModule.js',
211214
['app-icon-badge', appIconBadgeConfig],
212215
],
213216
extra: {

expo-env.d.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
/// <reference types="expo/types" />
22

3-
// NOTE: This file should not be edited and should be in your git ignore
3+
// NOTE: This file should not be edited and should be in your git ignore

package.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@
110110
"axios": "~1.12.0",
111111
"babel-plugin-module-resolver": "^5.0.2",
112112
"buffer": "^6.0.3",
113-
"countly-sdk-react-native-bridge": "^25.4.0",
113+
"countly-sdk-react-native-bridge": "25.4.1",
114114
"date-fns": "^4.1.0",
115115
"expo": "~53.0.23",
116116
"expo-application": "~6.1.5",
@@ -148,6 +148,7 @@
148148
"mapbox-gl": "3.18.1",
149149
"moti": "~0.29.0",
150150
"nativewind": "~4.1.21",
151+
"promise": "8.3.0",
151152
"react": "19.0.0",
152153
"react-dom": "19.0.0",
153154
"react-error-boundary": "~4.0.13",
@@ -258,6 +259,7 @@
258259
"initVersion": "7.0.4"
259260
},
260261
"resolutions": {
261-
"form-data": "4.0.4"
262+
"form-data": "4.0.4",
263+
"promise": "8.3.0"
262264
}
263265
}

plugins/withInCallAudioModule.js

Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
1+
const { withDangerousMod, withMainApplication } = require('@expo/config-plugins');
2+
const fs = require('fs');
3+
const path = require('path');
4+
5+
/**
6+
* Android InCallAudioModule.kt content
7+
* Uses SoundPool to play sounds on the VOICE_COMMUNICATION stream.
8+
*/
9+
const ANDROID_MODULE = `package {{PACKAGE_NAME}}
10+
11+
import android.content.Context
12+
import android.media.AudioAttributes
13+
import android.media.AudioManager
14+
import android.media.SoundPool
15+
import android.os.Build
16+
import android.util.Log
17+
import com.facebook.react.bridge.*
18+
19+
class InCallAudioModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) {
20+
21+
companion object {
22+
private const val TAG = "InCallAudioModule"
23+
}
24+
25+
private var soundPool: SoundPool? = null
26+
private val soundMap = HashMap<String, Int>()
27+
private val loadedSounds = HashSet<Int>()
28+
private var isInitialized = false
29+
30+
override fun getName(): String {
31+
return "InCallAudioModule"
32+
}
33+
34+
@ReactMethod
35+
fun initializeAudio() {
36+
if (isInitialized) return
37+
38+
val audioAttributes = AudioAttributes.Builder()
39+
.setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION)
40+
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
41+
.build()
42+
43+
soundPool = SoundPool.Builder()
44+
.setMaxStreams(1)
45+
.setAudioAttributes(audioAttributes)
46+
.build()
47+
48+
soundPool?.setOnLoadCompleteListener { _, sampleId, status ->
49+
if (status == 0) {
50+
loadedSounds.add(sampleId)
51+
Log.d(TAG, "Sound loaded successfully: $sampleId")
52+
} else {
53+
Log.e(TAG, "Failed to load sound $sampleId, status: $status")
54+
}
55+
}
56+
57+
isInitialized = true
58+
Log.d(TAG, "InCallAudioModule initialized with USAGE_VOICE_COMMUNICATION")
59+
}
60+
61+
@ReactMethod
62+
fun loadSound(name: String, resourceName: String) {
63+
if (!isInitialized) initializeAudio()
64+
65+
val context = reactApplicationContext
66+
var resId = context.resources.getIdentifier(resourceName, "raw", context.packageName)
67+
68+
// Fallback: Try identifying without package name if first attempt fails (though context.packageName is usually correct)
69+
if (resId == 0) {
70+
Log.w(TAG, "Resource $resourceName not found in \${context.packageName}, trying simplified lookup")
71+
// Reflection-based lookup if needed, but getIdentifier is standard.
72+
}
73+
74+
if (resId != 0) {
75+
soundPool?.let { pool ->
76+
val soundId = pool.load(context, resId, 1)
77+
soundMap[name] = soundId
78+
Log.d(TAG, "Loading sound: $name from resource: $resourceName (id: $soundId, resId: $resId)")
79+
}
80+
} else {
81+
Log.e(TAG, "Resource not found: $resourceName in package \${context.packageName}")
82+
}
83+
}
84+
85+
@ReactMethod
86+
fun playSound(name: String) {
87+
val soundId = soundMap[name]
88+
if (soundId != null) {
89+
if (loadedSounds.contains(soundId)) {
90+
val streamId = soundPool?.play(soundId, 0.5f, 0.5f, 1, 0, 1.0f)
91+
if (streamId == 0) {
92+
Log.e(TAG, "Failed to play sound: $name (id: $soundId). StreamId is 0. Check Volume/Focus.")
93+
} else {
94+
Log.d(TAG, "Playing sound: $name (id: $soundId, stream: $streamId)")
95+
}
96+
} else {
97+
Log.w(TAG, "Sound $name (id: $soundId) is not ready yet. Ignoring play request.")
98+
}
99+
} else {
100+
Log.w(TAG, "Sound not found in map: $name")
101+
}
102+
}
103+
104+
@ReactMethod
105+
fun cleanup() {
106+
soundPool?.release()
107+
soundPool = null
108+
soundMap.clear()
109+
loadedSounds.clear()
110+
isInitialized = false
111+
Log.d(TAG, "InCallAudioModule cleaned up")
112+
}
113+
}
114+
`;
115+
116+
/**
117+
* Android InCallAudioPackage.kt content
118+
*/
119+
const ANDROID_PACKAGE = `package {{PACKAGE_NAME}}
120+
121+
import android.view.View
122+
import com.facebook.react.ReactPackage
123+
import com.facebook.react.bridge.NativeModule
124+
import com.facebook.react.bridge.ReactApplicationContext
125+
import com.facebook.react.uimanager.ReactShadowNode
126+
import com.facebook.react.uimanager.ViewManager
127+
128+
class InCallAudioPackage : ReactPackage {
129+
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> {
130+
return listOf(InCallAudioModule(reactContext))
131+
}
132+
133+
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<View, ReactShadowNode<*>>> {
134+
return emptyList()
135+
}
136+
}
137+
`;
138+
139+
/**
140+
* Helper to resolve package name
141+
*/
142+
function resolveBasePackageName(projectRoot, fallback = 'com.resgrid.unit') {
143+
const namespaceRegex = /namespace\s*(?:=)?\s*['"]([^'"]+)['"]/;
144+
145+
const groovyPath = path.join(projectRoot, 'android', 'app', 'build.gradle');
146+
if (fs.existsSync(groovyPath)) {
147+
const content = fs.readFileSync(groovyPath, 'utf-8');
148+
const match = content.match(namespaceRegex);
149+
if (match) return match[1];
150+
}
151+
152+
const ktsPath = path.join(projectRoot, 'android', 'app', 'build.gradle.kts');
153+
if (fs.existsSync(ktsPath)) {
154+
const content = fs.readFileSync(ktsPath, 'utf-8');
155+
const match = content.match(namespaceRegex);
156+
if (match) return match[1];
157+
}
158+
159+
return fallback;
160+
}
161+
162+
const withInCallAudioModule = (config) => {
163+
// 1. Copy Assets to Android res/raw
164+
config = withDangerousMod(config, [
165+
'android',
166+
async (config) => {
167+
const projectRoot = config.modRequest.projectRoot;
168+
const resRawPath = path.join(projectRoot, 'android', 'app', 'src', 'main', 'res', 'raw');
169+
170+
if (!fs.existsSync(resRawPath)) {
171+
fs.mkdirSync(resRawPath, { recursive: true });
172+
}
173+
174+
const assets = ['software_interface_start.mp3', 'software_interface_back.mp3', 'positive_interface_beep.mp3', 'space_notification1.mp3', 'space_notification2.mp3'];
175+
176+
const sourceBase = path.join(projectRoot, 'assets', 'audio', 'ui');
177+
178+
assets.forEach((filename) => {
179+
const sourcePath = path.join(sourceBase, filename);
180+
const destPath = path.join(resRawPath, filename);
181+
182+
if (fs.existsSync(sourcePath)) {
183+
fs.copyFileSync(sourcePath, destPath);
184+
console.log(`[withInCallAudioModule] Copied ${filename} to res/raw/${filename}`);
185+
} else {
186+
console.warn(`[withInCallAudioModule] Source audio file not found: ${sourcePath}`);
187+
}
188+
});
189+
190+
return config;
191+
},
192+
]);
193+
194+
// 2. Add Native Module Code
195+
config = withDangerousMod(config, [
196+
'android',
197+
async (config) => {
198+
const projectRoot = config.modRequest.projectRoot;
199+
const packageName = resolveBasePackageName(projectRoot);
200+
const packagePath = packageName.replace(/\./g, '/');
201+
const androidSrcPath = path.join(projectRoot, 'android', 'app', 'src', 'main', 'java', packagePath);
202+
203+
if (!fs.existsSync(androidSrcPath)) {
204+
fs.mkdirSync(androidSrcPath, { recursive: true });
205+
}
206+
207+
// InCallAudioModule.kt
208+
const modulePath = path.join(androidSrcPath, 'InCallAudioModule.kt');
209+
const moduleContent = ANDROID_MODULE.replace(/\{\{PACKAGE_NAME\}\}/g, packageName);
210+
fs.writeFileSync(modulePath, moduleContent);
211+
console.log('[withInCallAudioModule] Created InCallAudioModule.kt');
212+
213+
// InCallAudioPackage.kt
214+
const packageFilePath = path.join(androidSrcPath, 'InCallAudioPackage.kt');
215+
const packageContent = ANDROID_PACKAGE.replace(/\{\{PACKAGE_NAME\}\}/g, packageName);
216+
fs.writeFileSync(packageFilePath, packageContent);
217+
console.log('[withInCallAudioModule] Created InCallAudioPackage.kt');
218+
219+
return config;
220+
},
221+
]);
222+
223+
// 3. Register Package in MainApplication.kt
224+
config = withMainApplication(config, (config) => {
225+
const mainApplication = config.modResults;
226+
const projectRoot = config.modRequest.projectRoot;
227+
228+
if (!mainApplication.contents.includes('InCallAudioPackage')) {
229+
const basePackageName = resolveBasePackageName(projectRoot);
230+
const importStatement = `import ${basePackageName}.InCallAudioPackage`;
231+
232+
if (!mainApplication.contents.includes(importStatement)) {
233+
mainApplication.contents = mainApplication.contents.replace(/^(package\s+[^\n]+\n)/, `$1${importStatement}\n`);
234+
}
235+
236+
const packagesPattern = /val packages = PackageList\(this\)\.packages(\.toMutableList\(\))?/;
237+
const packagesMatch = mainApplication.contents.match(packagesPattern);
238+
239+
if (packagesMatch) {
240+
// Using the simplest replacement that ensures toMutableList()
241+
const replacement = `val packages = PackageList(this).packages.toMutableList()\n packages.add(InCallAudioPackage())`;
242+
243+
// Avoid double adding if MediaButtonPackage logic already changed it to mutable
244+
if (mainApplication.contents.includes('packages.add(MediaButtonPackage()')) {
245+
// Add ours after MediaButtonPackage
246+
mainApplication.contents = mainApplication.contents.replace('packages.add(MediaButtonPackage())', 'packages.add(MediaButtonPackage())\n packages.add(InCallAudioPackage())');
247+
} else {
248+
// Standard replacement
249+
mainApplication.contents = mainApplication.contents.replace(packagesPattern, replacement);
250+
}
251+
console.log('[withInCallAudioModule] Registered InCallAudioPackage in MainApplication.kt');
252+
}
253+
}
254+
255+
return config;
256+
});
257+
258+
return config;
259+
};
260+
261+
module.exports = withInCallAudioModule;

src/app/login/login-form.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,3 +182,5 @@ export const LoginForm = ({ onSubmit = () => {}, isLoading = false, error = unde
182182
</KeyboardAvoidingView>
183183
);
184184
};
185+
186+
export default LoginForm;

0 commit comments

Comments
 (0)