From 6b2cd1d9668a2986e1820e336769e03759eeea0a Mon Sep 17 00:00:00 2001 From: David Date: Wed, 15 Oct 2025 15:40:30 -0400 Subject: [PATCH 01/14] Got mediasoup working! --- app/build.gradle.kts | 10 +- .../LoginActivity.kt | 5 +- .../neurology_project_android/MainActivity.kt | 52 +- .../neurology_project_android/RoomClient.kt | 301 ++++++++ .../SignalingClient.kt | 677 +++--------------- .../VideoCameraSetup.kt | 334 ++++----- .../sampledata/Consumer.java | 35 + .../sampledata/Device.java | 476 ++++++++++++ .../sampledata/Engine.java | 314 ++++++++ .../sampledata/LocalAudioSource.java | 25 + .../sampledata/LocalSource.java | 16 + .../sampledata/LocalVideoSource.java | 86 +++ .../sampledata/Peer.java | 32 + .../sampledata/Player.java | 49 ++ .../sampledata/RecvTransport.java | 184 +++++ .../sampledata/SendTransport.java | 177 +++++ .../sampledata/Transport.java | 154 ++++ gradle.properties | 3 +- 18 files changed, 2161 insertions(+), 769 deletions(-) create mode 100644 app/src/main/java/com/example/neurology_project_android/RoomClient.kt create mode 100644 app/src/main/java/com/example/neurology_project_android/sampledata/Consumer.java create mode 100644 app/src/main/java/com/example/neurology_project_android/sampledata/Device.java create mode 100644 app/src/main/java/com/example/neurology_project_android/sampledata/Engine.java create mode 100644 app/src/main/java/com/example/neurology_project_android/sampledata/LocalAudioSource.java create mode 100644 app/src/main/java/com/example/neurology_project_android/sampledata/LocalSource.java create mode 100644 app/src/main/java/com/example/neurology_project_android/sampledata/LocalVideoSource.java create mode 100644 app/src/main/java/com/example/neurology_project_android/sampledata/Peer.java create mode 100644 app/src/main/java/com/example/neurology_project_android/sampledata/Player.java create mode 100644 app/src/main/java/com/example/neurology_project_android/sampledata/RecvTransport.java create mode 100644 app/src/main/java/com/example/neurology_project_android/sampledata/SendTransport.java create mode 100644 app/src/main/java/com/example/neurology_project_android/sampledata/Transport.java diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 8468bbf..866d65f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -27,11 +27,11 @@ android { "proguard-rules.pro" ) - buildConfigField("String", "BASE_API_URL", "\"https://videochat-signaling-app.ue.r.appspot.com\"") + buildConfigField("String", "BASE_API_URL", "\"https://meechie.techkit.xyz\"") buildConfigField("String", "BASE_WS_API_URL", "\"wss://videochat-signaling-app.ue.r.appspot.com\"") - buildConfigField("int", "PORT", "443") + buildConfigField("int", "PORT", "3016") buildConfigField("boolean", "SECURE", "true") // yes use HTTPS - buildConfigField("String", "API_POST_URL","\"https://videochat-signaling-app.ue.r.appspot.com/key=peerjs/post\"") + buildConfigField("String", "API_POST_URL","\"https://meechie.techkit.xyz:3016/key=peerjs/post\"") buildConfigField("String", "API_GET_PEERS_URL","\"https://videochat-signaling-app.ue.r.appspot.com/key=peerjs/peers\"") signingConfig = signingConfigs.getByName("debug") } @@ -100,9 +100,11 @@ android { } dependencies { + implementation ("com.github.0-u-0:mediasoup-android-sdk:0.0.1") + implementation("com.github.0-u-0:dugon-webrtc-android:100.0.2") + implementation("io.socket:socket.io-client:2.1.1") implementation ("com.github.franmontiel:PersistentCookieJar:v1.0.1") implementation("com.squareup.okhttp3:okhttp:5.0.0-alpha.14") - implementation("io.github.webrtc-sdk:android:125.6422.06.1") implementation("androidx.compose.material:material-icons-extended") implementation("androidx.room:room-runtime:2.5.0") implementation(libs.androidx.camera.core) diff --git a/app/src/main/java/com/example/neurology_project_android/LoginActivity.kt b/app/src/main/java/com/example/neurology_project_android/LoginActivity.kt index 8997ad5..e6fc8d6 100644 --- a/app/src/main/java/com/example/neurology_project_android/LoginActivity.kt +++ b/app/src/main/java/com/example/neurology_project_android/LoginActivity.kt @@ -150,8 +150,11 @@ fun LoginScreen(sessionManager: SessionManager, onLoginSuccess: () -> Unit) { override fun onResponse(call: Call, response: Response) { val success = response.isSuccessful val bodyString = response.body?.string()?.trim() ?: "" - var authToken = response.headers.value(9)?.substringAfter("authorization=")?.substringBefore(";") ?: "" + var authToken = response.headers.get("Set-Cookie") + ?.substringBefore(";") ?: "" + Log.d("LOGINACTIVITY:", "RESPONSE HEADERS: " + response.headers.get("Set-Cookie")) + Log.d("LOGINACTIVITY:", "AUTH TOKEN: " + authToken) if (success) { sessionManager.saveAuthToken(authToken, username) (context as ComponentActivity).runOnUiThread { diff --git a/app/src/main/java/com/example/neurology_project_android/MainActivity.kt b/app/src/main/java/com/example/neurology_project_android/MainActivity.kt index f6f8d2c..7f99806 100644 --- a/app/src/main/java/com/example/neurology_project_android/MainActivity.kt +++ b/app/src/main/java/com/example/neurology_project_android/MainActivity.kt @@ -62,9 +62,6 @@ import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.Response import okio.IOException -import org.webrtc.CapturerObserver -import org.webrtc.VideoProcessor -import org.webrtc.VideoSource import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -73,9 +70,9 @@ class MainActivity : ComponentActivity() { @SuppressLint("RestrictedApi") private lateinit var cameraRequest: CameraRequest - private lateinit var videoProcessor: VideoProcessor - private lateinit var videoSource: VideoSource - private lateinit var capturerObserver: CapturerObserver +// private lateinit var videoProcessor: VideoProcessor +// private lateinit var videoSource: VideoSource +// private lateinit var capturerObserver: CapturerObserver private var isInCall by mutableStateOf(false) private var cameraInitialized by mutableStateOf(false) private lateinit var signalingClient: SignalingClient @@ -114,21 +111,24 @@ class MainActivity : ComponentActivity() { val fetchedId = fetchUserId() userIdState.value = fetchedId - // Now safe to start SignalingClient + //Now safe to start SignalingClient signalingClient = SignalingClient( - "$BASE_WS_API_URL:$PORT/peerjs?id=$fetchedId&token=6789&key=peerjs", this@MainActivity, - fetchedId, - onCallRecieved = { isInCall = true }, - onCallEnded = { runOnUiThread { isInCall = false } } - ) - // Fetch peers - GetPeers { peers -> - runOnUiThread { - peersState.value = peers.filter { it != fetchedId } + { peers -> + runOnUiThread { + peersState.value = peers.filter { it != fetchedId } + } } - } + + ) + +// // Fetch peers +// GetPeers { peers -> +// runOnUiThread { +// peersState.value = peers.filter { it != fetchedId } +// } +// } } val userId = userIdState.value @@ -150,14 +150,14 @@ class MainActivity : ComponentActivity() { peerId = userId, peers = peersState.value ) - Greeting( - name = "Android", - modifier = Modifier.padding(innerPadding), - signalingClient = signalingClient, - cameraInitialized = cameraInitialized, - cameraRequest = { cameraRequest }, - isInCall = isInCall - ) +// Greeting( +// name = "Android", +// modifier = Modifier.padding(innerPadding), +// signalingClient = signalingClient, +// cameraInitialized = cameraInitialized, +// cameraRequest = { cameraRequest }, +// isInCall = isInCall +// ) } ) } @@ -209,7 +209,7 @@ class MainActivity : ComponentActivity() { .padding(end = 16.dp) ) Button( - onClick = { signalingClient.startCall(userId) }, + onClick = { signalingClient.joinRoom(userId) }, //signalingClient.startCall(userId) modifier = Modifier.wrapContentWidth() ) { Text(text = "Call") diff --git a/app/src/main/java/com/example/neurology_project_android/RoomClient.kt b/app/src/main/java/com/example/neurology_project_android/RoomClient.kt new file mode 100644 index 0000000..3cc8553 --- /dev/null +++ b/app/src/main/java/com/example/neurology_project_android/RoomClient.kt @@ -0,0 +1,301 @@ +package com.example.neurology_project_android + +import android.content.Context +import androidx.annotation.OptIn +import androidx.media3.common.util.Log +import androidx.media3.common.util.UnstableApi +import com.example.neurology_project_android.sampledata.Device +import com.google.gson.Gson +import com.google.gson.JsonArray +import com.google.gson.JsonObject +import io.socket.client.Ack +import io.socket.client.Socket + +import org.json.JSONArray +import org.json.JSONObject +import java.util.function.Consumer +import java.util.function.Function + +class RoomClient constructor(room_id: String, name: String, socket: Socket, context: Context) { + private var localMedia = null; + private var remoteMedia = null; + + private lateinit var socket: Socket + private lateinit var sendTransportId:String + + @OptIn(UnstableApi::class) + fun requestProducer(producerTransportId: String, kind: String, rtpParams: String){ + Log.d("REQUESTPRODUCER: ", "IN REQUEST PRODUCER") + val jsonPayload = JSONObject().apply { put("producerTransportId", producerTransportId) + put("kind", kind) + put("rtpParameters", JSONObject(rtpParams))} + + socket.emit("produce", jsonPayload, Ack { args -> + + // This block runs when the server executes 'callback(roomList)' + + if (args.isEmpty() || args[0] == null) { + Log.e("SIGNALING CLIENT", "no producer recv") + return@Ack + } + + // Assuming the room list is the first argument in the callback's arguments array + val responseData = args[0] + + if (responseData is JSONObject) { + + + Log.d("REQUEST PRODUCER", "SUCCESS! producer Recd $responseData") + // 1. Initialize a mutable list to hold the extracted room IDs +// val gson = Gson() +// val jsonObject:JsonObject = gson.fromJson(responseData.toString(), JsonObject::class.java) +// +// +//// +//// Device.load(responseData) +// +// Device.load(jsonObject) + + + + + + // TODO: Update ViewModel/Activity state here + + } else { + Log.e("SIGNALING CLIENT", "Received unexpected response type: ${responseData.javaClass.name}") + } + }) + } + + @OptIn(UnstableApi::class) + private fun requestConnectTransport( + t: JSONObject, + producerId: String + ) { + val jsonPayload = JSONObject().apply { put("transport_id", producerId) + put("dtlsParameters", t) + } + socket.emit("connectTransport", jsonPayload) + Log.d("ROOMCLIENT", "EMITTED CONNECT TRANSPORT" + t.toString()) + + + } + @OptIn(UnstableApi::class) + fun createWebRTCTransport(){ + val jsonPayload = JSONObject().apply { put("forceTcp", "false") + put("rtpCapabilities", Device.rtpCapabilities)} + socket.emit("createWebRtcTransport", jsonPayload, Ack { args -> + + // This block runs when the server executes 'callback(roomList)' + + if (args.isEmpty() || args[0] == null) { + Log.e("SIGNALING CLIENT", "no router rtpCaps") + return@Ack + } + + // Assuming the room list is the first argument in the callback's arguments array + val responseData = args[0] + + if (responseData is JSONObject) { + + + Log.d("SIGNALING CLIENT", "SUCCESS! transport created: $responseData") + // 1. Initialize a mutable list to hold the extracted room IDs + + + + val gson = Gson() + val iceParameters:JsonObject = gson.fromJson(responseData.get("iceParameters").toString(), JsonObject::class.java) + val iceCandidates: JSONArray = responseData.getJSONArray("iceCandidates") + val iceCandidatesJson: JsonArray = gson.fromJson(iceCandidates.toString(), JsonArray::class.java) + val dtlsParameters:JsonObject = gson.fromJson(responseData.get("dtlsParameters").toString(), JsonObject::class.java) +// +// Device.load(responseData) + val producerTransport = Device.createSendTransport(responseData.get("id").toString(), iceParameters, + iceCandidatesJson, dtlsParameters) + + sendTransportId = responseData.get("id").toString() + Log.d("ROOM CLIENT", "CREATED PRODUCER TRANSPORT: " + producerTransport.toString()) + + producerTransport.onProduce = object : Function { + override fun apply(pData: JSONObject): String { + Log.d("ROOM CLIENT", "IN ONPRODUCE") + // The 'apply' method is the Single Abstract Method (SAM) that must be implemented + Log.d("ROOM CLIENT", "IN PRODUCER TRANSPORT ONPRODUCE" + pData.toString()) + var rtpParams = pData.get("rtpParameters").toString() + + var kind = pData.get("kind").toString() + + requestProducer(sendTransportId, kind, rtpParams) + return "hi" + } + } + + producerTransport.transport.onConnect = Consumer {dtlsParameters: JSONObject -> + + Log.d("ROOM CLIENT", "ON CONNECT" + dtlsParameters.toString()) + + requestConnectTransport(dtlsParameters, sendTransportId) + + + } + + + + + var localVideoSource = Device.createVideoSource() + producerTransport.send(localVideoSource) + var producerId = producerTransport.onProduce.apply(JSONObject(producerTransport.produceData.toString())) + Log.d("producerIDasdfsdaf", producerId.toString()) + + + // TODO: Update ViewModel/Activity state here + + } else { + Log.e("SIGNALING CLIENT", "Received unexpected response type: ${responseData.javaClass.name}") + } + }) + + + + + } + + @OptIn(UnstableApi::class) + fun getRouterRtpCapabilities(){ + val jsonPayload = JSONObject().apply { } + socket.emit("getRouterRtpCapabilities", jsonPayload, Ack { args -> + + // This block runs when the server executes 'callback(roomList)' + + if (args.isEmpty() || args[0] == null) { + Log.e("SIGNALING CLIENT", "no router rtpCaps") + return@Ack + } + + // Assuming the room list is the first argument in the callback's arguments array + val responseData = args[0] + + if (responseData is JSONObject) { + + + Log.d("SIGNALING CLIENT", "SUCCESS! routerRTCCapabilities Recv $responseData") + // 1. Initialize a mutable list to hold the extracted room IDs + val gson = Gson() + val jsonObject:JsonObject = gson.fromJson(responseData.toString(), JsonObject::class.java) + + +// +// Device.load(responseData) + + Device.load(jsonObject) + + + + + + // TODO: Update ViewModel/Activity state here + + } else { + Log.e("SIGNALING CLIENT", "Received unexpected response type: ${responseData.javaClass.name}") + } + }) + + } + + + @OptIn(UnstableApi::class) + fun joinRoom(room_id: String, name: String){ + + val jsonPayload = JSONObject().apply { put("room_id", room_id) + put("name", name)} + socket.emit("join", jsonPayload, Ack { args -> + + // This block runs when the server executes 'callback(roomList)' + + if (args.isEmpty() || args[0] == null) { + Log.e("SIGNALING CLIENT", "no room joined") + return@Ack + } + + // Assuming the room list is the first argument in the callback's arguments array + val responseData = args[0] + + if (responseData is JSONObject) { + + + Log.d("SIGNALING CLIENT", "SUCCESS! Room joined: $responseData") + // 1. Initialize a mutable list to hold the extracted room IDs + + + + + + + + // TODO: Update ViewModel/Activity state here + + } else { + Log.e("SIGNALING CLIENT", "Received unexpected response type: ${responseData.javaClass.name}") + } + }) + getRouterRtpCapabilities() + createWebRTCTransport() + } + @OptIn(UnstableApi::class) + fun createRoom(room_id: String){ + val emptyPayload = JSONObject() + + socket.emit("createRoom", { room_id}, Ack { args -> + + // This block runs when the server executes 'callback(roomList)' + + if (args.isEmpty() || args[0] == null) { + Log.e("SIGNALING CLIENT", "no room created") + return@Ack + } + + // Assuming the room list is the first argument in the callback's arguments array + val responseData = args[0] + + if (responseData is JSONArray) { + + + Log.d("SIGNALING CLIENT", "SUCCESS! Room List received: $responseData") + // 1. Initialize a mutable list to hold the extracted room IDs + + var roomList = mutableListOf() + + // 2. Loop through the JSONArray + for (i in 0 until responseData.length()) { + // 3. Safely extract each element as a String + val roomId = responseData.getString(i) + roomList.add(roomId) + + } + + + + + // TODO: Update ViewModel/Activity state here + + } else { + Log.e("SIGNALING CLIENT", "Received unexpected response type: ${responseData.javaClass.name}") + } + }) + } + + private var device = Device() + init { + this.socket = socket + Device.initialize(context) + + + + joinRoom(room_id, "david_android") + + } + + +} \ No newline at end of file diff --git a/app/src/main/java/com/example/neurology_project_android/SignalingClient.kt b/app/src/main/java/com/example/neurology_project_android/SignalingClient.kt index 6e39b7e..b279430 100644 --- a/app/src/main/java/com/example/neurology_project_android/SignalingClient.kt +++ b/app/src/main/java/com/example/neurology_project_android/SignalingClient.kt @@ -3,295 +3,70 @@ package com.example.neurology_project_android import android.content.Context import android.hardware.camera2.CameraManager import android.media.AudioManager +import android.media.metrics.Event import android.os.Build import androidx.annotation.OptIn import androidx.annotation.RequiresApi +import androidx.compose.runtime.remember import androidx.media3.common.util.Log import androidx.media3.common.util.UnstableApi +import io.socket.client.Ack +import io.socket.client.IO +import io.socket.client.Manager +import io.socket.client.Socket import okhttp3.FormBody import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.Response import okhttp3.WebSocket import okhttp3.WebSocketListener +import one.dugon.mediasoup_android_sdk.Engine +import one.dugon.mediasoup_android_sdk.Engine.Listener +import org.json.JSONArray import org.json.JSONObject -import org.webrtc.Camera2Capturer -import org.webrtc.CameraVideoCapturer.CameraEventsHandler -import org.webrtc.DataChannel -import org.webrtc.DefaultVideoDecoderFactory -import org.webrtc.DefaultVideoEncoderFactory -import org.webrtc.EglBase -import org.webrtc.IceCandidate -import org.webrtc.MediaConstraints -import org.webrtc.MediaStream -import org.webrtc.PeerConnection -import org.webrtc.PeerConnectionFactory -import org.webrtc.SdpObserver -import org.webrtc.SessionDescription -import org.webrtc.SurfaceTextureHelper -import org.webrtc.VideoTrack -import org.webrtc.VideoProcessor -import org.webrtc.VideoSource +import kotlin.concurrent.fixedRateTimer +import kotlin.contracts.contract + +//import org.webrtc.Camera2Capturer +//import org.webrtc.CameraVideoCapturer.CameraEventsHandler +//import org.webrtc.DataChannel +//import org.webrtc.DefaultVideoDecoderFactory +//import org.webrtc.DefaultVideoEncoderFactory +//import org.webrtc.EglBase +//import org.webrtc.IceCandidate +//import org.webrtc.MediaConstraints +//import org.webrtc.MediaStream +//import org.webrtc.PeerConnection +//import org.webrtc.PeerConnectionFactory +//import org.webrtc.SdpObserver +//import org.webrtc.SessionDescription +//import org.webrtc.SurfaceTextureHelper +//import org.webrtc.VideoTrack +//import org.webrtc.VideoProcessor +//import org.webrtc.VideoSource @RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM) class SignalingClient @OptIn(UnstableApi::class) constructor ( - url: String, - context: Context, ourID: String, - private val onCallRecieved: () -> Unit, - private val onCallEnded: () -> Unit + context: Context, + private val onPeersFetched: (List) -> Unit + ) { - private lateinit var localPeer: PeerConnection +// private lateinit var localPeer: PeerConnection private lateinit var httpUrl: String private lateinit var theirID: String - private var ourID = ourID + private lateinit var context: Context private lateinit var webSocketListener: WebSocketListener private lateinit var client: OkHttpClient private lateinit var mediaID: String private lateinit var webSocket: WebSocket - private lateinit var localSDP: SessionDescription - private lateinit var track: VideoTrack - private val server = - PeerConnection.IceServer.builder("stun:stun.l.google.com:19302").createIceServer() - private var candidatesList = ArrayList() +// private lateinit var localSDP: SessionDescription +// private lateinit var track: VideoTrack +// private val server = +// PeerConnection.IceServer.builder("stun:stun.l.google.com:19302").createIceServer() +// private var candidatesList = ArrayList() private var isReadyToAddIceCandidate: Boolean = false private var candidateMessagesToSend = ArrayList() - private lateinit var camera1Capturer: Camera2Capturer - private lateinit var videoSource: VideoSource - private lateinit var factory: PeerConnectionFactory - private lateinit var rootEGL: EglBase - private lateinit var sdpObserver: SdpObserver - - fun setLocalSDP() { - localPeer.setLocalDescription(remoteObserver, localSDP) - } - - private var remoteObserver = object : SdpObserver { - @OptIn(UnstableApi::class) - override fun onCreateSuccess(sdp: SessionDescription?) { - Log.d("RemoteObserver", "Answer SDP was Created") - if (sdp != null) { - localSDP = sdp - setLocalSDP() - } - val sdpMsg = JSONObject() - sdpMsg.accumulate("type", "answer") - sdpMsg.accumulate( - "sdp", - sdp?.description - ) - - - val payload = JSONObject() - payload.accumulate("sdp", sdpMsg) - payload.accumulate("type", "media") - payload.accumulate("browser", "firefox") - payload.accumulate("connectionId", mediaID) - - val msg = JSONObject() - msg.accumulate("type", "ANSWER") - msg.accumulate("payload", payload) - msg.accumulate("dst", theirID) - - val formBody = - FormBody.Builder().add("type", "ANSWER").add("payload", payload.toString()) - .add("dst", theirID).build() - - - var request = Request.Builder().url(httpUrl).post(formBody).build() - - webSocket.send(msg.toString()) - - for (candidate in candidatesList) { - val status = localPeer.addIceCandidate(candidate) - Log.d("Adding ICE CANDIDATE", status.toString()) - } - - isReadyToAddIceCandidate = true - - } - - @OptIn(UnstableApi::class) - override fun onSetSuccess() { - Log.d("SignalingClient", "RemoteSDP set successfully") - - val mediaConstraints1 = MediaConstraints.KeyValuePair( - "kRTCMediaConstraintsOfferToReceiveAudio", - "kRTCMediaConstraintsValueTrue" - ) - val mediaConstraints2 = MediaConstraints.KeyValuePair( - "kRTCMediaConstraintsOfferToReceiveVideo", - "kRTCMediaConstraintsValueTrue" - ) - val mediaConstraints3 = MediaConstraints.KeyValuePair( - "kRTCMediaStreamTrackKindVideo", - "kRTCMediaConstraintsValueTrue" - ) - - val mediaConstraints4 = MediaConstraints.KeyValuePair( - "DtlsSrtpKeyAgreement", - "kRTCMediaConstraintsValueTrue" - ) - - val mediaConstraints5 = MediaConstraints.KeyValuePair("setup", "actpass") - val mediaConstraints6 = MediaConstraints.KeyValuePair( - "video", - "true" - ) - val mediaConstraints = MediaConstraints() - - - mediaConstraints.mandatory.add(mediaConstraints1) - mediaConstraints.mandatory.add(mediaConstraints2) - mediaConstraints.mandatory.add(mediaConstraints3) - mediaConstraints.mandatory.add(mediaConstraints4) - mediaConstraints.mandatory.add(mediaConstraints5) - mediaConstraints.mandatory.add(mediaConstraints6) - localPeer.createAnswer(this, mediaConstraints) - } - - @OptIn(UnstableApi::class) - override fun onCreateFailure(error: String?) { - Log.d("OnCreateFailure", error.toString()) - } - - @OptIn(UnstableApi::class) - override fun onSetFailure(error: String?) { - Log.d("SDP Observer", error.toString()) - } - - } - - private var peerConnObserver = object : PeerConnection.Observer { - @OptIn(UnstableApi::class) - override fun onSignalingChange(p0: PeerConnection.SignalingState?) { - if (p0 != null) { - Log.d("Signaling State", "SignalingChange " + p0.name) - } - - - } - - @OptIn(UnstableApi::class) - override fun onIceConnectionChange(p0: PeerConnection.IceConnectionState?) { - Log.d("ICE Connection", p0.toString()) - - if (p0 == PeerConnection.IceConnectionState.DISCONNECTED || - p0 == PeerConnection.IceConnectionState.CLOSED) { - onCallEnded() // Notify MainActivity when the call ends - } - } - - override fun onIceConnectionReceivingChange(p0: Boolean) { - //TODO("Not yet implemented") - } - - @OptIn(UnstableApi::class) - override fun onIceGatheringChange(p0: PeerConnection.IceGatheringState?) { - Log.d("ICEGATHERINGSTATE", p0.toString()) - } - - @OptIn(UnstableApi::class) - override fun onIceCandidate(p0: IceCandidate?) { - val candidate = JSONObject() - candidate.accumulate("candidate", p0!!.sdp) - candidate.accumulate("sdpMLineIndex", p0.sdpMLineIndex) - candidate.accumulate("sdpMid", p0.sdpMid) - - val payload = JSONObject() - payload.accumulate("candidate", candidate) - payload.accumulate("connectionId", mediaID) - payload.accumulate("type", "media") - - val message = JSONObject() - message.accumulate("payload", payload) - message.accumulate("type", "CANDIDATE") - message.accumulate("dst", theirID) - - webSocket.send(message.toString()) - - Log.d("REC ICECandidate", p0.toString()) - } - - override fun onIceCandidatesRemoved(p0: Array?) { - //TODO("Not yet implemented") - } - - @OptIn(UnstableApi::class) - override fun onAddStream(p0: MediaStream?) { - - if (p0 != null) { - // Handle remote video track - if (p0.videoTracks.isNotEmpty()) { - val remoteVideoTrack = p0.videoTracks[0] - //localPeer.addTrack(remoteVideoTrack) - // You need a way to pass this remoteVideoTrack to your UI - // For example, if you have a SurfaceViewRenderer in your Activity/Fragment: - // yourRemoteVideoRenderer.addTrack(remoteVideoTrack) - Log.d("PeerConnection", "Remote Video Track received: ${remoteVideoTrack.id()}") - } - // Handle remote audio track (WebRTC usually plays this automatically once received) - if (p0.audioTracks.isNotEmpty()) { - val remoteAudioTrack = p0.audioTracks[0] - //localPeer.addTrack(remoteAudioTrack) - Log.d("PeerConnection", "Remote Audio Track received: ${remoteAudioTrack.id()}") - } - } - - } - - override fun onRemoveStream(p0: MediaStream?) { - //TODO("Not yet implemented") - } - - @OptIn(UnstableApi::class) - override fun onDataChannel(p0: DataChannel?) { - - Log.d("PeerConnection", "DataChannel added") - - //TODO("Not yet implemented") - } - - @OptIn(UnstableApi::class) - override fun onRenegotiationNeeded() { - Log.d("PeerConnection", "Renegotation Needed") - - } - - } - - private var cameraEventsHandler = object : CameraEventsHandler { - @OptIn(UnstableApi::class) - override fun onCameraError(p0: String?) { - Log.d("CAMERA ERROR", p0!!) - //TODO("Not yet implemented") - } - - override fun onCameraDisconnected() { - //TODO("Not yet implemented") - } - - override fun onCameraFreezed(p0: String?) { - - } - - @OptIn(UnstableApi::class) - override fun onCameraOpening(p0: String?) { - Log.d("CAMERA EVENTS", "CAMERA OPEN") - //TODO("Not yet implemented") - } - - @OptIn(UnstableApi::class) - override fun onFirstFrameAvailable() { - Log.d("CAMERA", "FIRST FRAME") - } - - @OptIn(UnstableApi::class) - override fun onCameraClosed() { - Log.d("CAMERA","Camera Closed") - } - - } val availabilityCallback = object : CameraManager.AvailabilityCallback() { @OptIn(UnstableApi::class) @@ -315,24 +90,6 @@ class SignalingClient @OptIn(UnstableApi::class) constructor - private fun generateConfig(): PeerConnection.RTCConfiguration { - val config = PeerConnection.RTCConfiguration(listOf(server)) - config.sdpSemantics = PeerConnection.SdpSemantics.UNIFIED_PLAN - - config.continualGatheringPolicy = PeerConnection.ContinualGatheringPolicy.GATHER_ONCE - config.iceTransportsType = PeerConnection.IceTransportsType.ALL - return config - } - - private fun buildFactory(rootEGL: EglBase): PeerConnectionFactory? { - - val encoderFactory = DefaultVideoEncoderFactory(rootEGL.eglBaseContext, true, true) - val decoderFactory = DefaultVideoDecoderFactory(rootEGL.eglBaseContext) - val factory = PeerConnectionFactory.builder().setVideoDecoderFactory(decoderFactory) - .setVideoEncoderFactory(encoderFactory).createPeerConnectionFactory() - return factory - } - @@ -340,12 +97,6 @@ class SignalingClient @OptIn(UnstableApi::class) constructor private fun buildVideoSenders(context: Context, url: String) { - val options = PeerConnectionFactory.InitializationOptions.builder(context) - .createInitializationOptions() - PeerConnectionFactory.initialize(options) - rootEGL = EglBase.create() - factory = buildFactory(rootEGL)!! - val cameraManager = context.getSystemService(Context.CAMERA_SERVICE) as CameraManager cameraManager.registerAvailabilityCallback(availabilityCallback, null) @@ -356,328 +107,114 @@ class SignalingClient @OptIn(UnstableApi::class) constructor val cameraList = cameraManager.cameraIdList val camera01 = cameraManager.cameraIdList.first() val camera02 = cameraManager.cameraIdList.last() - camera1Capturer = Camera2Capturer(context, camera02, cameraEventsHandler) - videoSource = factory?.createVideoSource(true)!! - audioManager.toString() -// -// - val surfaceTexture2 = SurfaceTextureHelper.create("CaptureThread", rootEGL.eglBaseContext) -// //var test = MultiMediaClient() - camera1Capturer.initialize(surfaceTexture2, context, videoSource!!.capturerObserver) - val config2 = generateConfig() - -// - localPeer = factory.createPeerConnection(config2, peerConnObserver)!! - camera1Capturer.startCapture(1920, 1080, 30) + client = OkHttpClient().newBuilder().build() httpUrl = url AudioManager.ADJUST_UNMUTE val audioDeviceInfo = audioManager.getDevices(AudioManager.GET_DEVICES_INPUTS) Log.d("Signaling Client", "Audio devices" + audioDeviceInfo.size) - val mediaConstraints1 = MediaConstraints.KeyValuePair( - "kRTCMediaConstraintsOfferToReceiveAudio", - "kRTCMediaConstraintsValueTrue" - ) - val mediaConstraints2 = MediaConstraints.KeyValuePair( - "kRTCMediaConstraintsOfferToReceiveVideo", - "kRTCMediaConstraintsValueTrue" - ) - val mediaConstraints3 = MediaConstraints.KeyValuePair( - "kRTCMediaStreamTrackKindVideo", - "kRTCMediaConstraintsValueTrue" - ) - - val mediaConstraints4 = MediaConstraints.KeyValuePair( - "DtlsSrtpKeyAgreement", - "kRTCMediaConstraintsValueTrue" - ) - - val mediaConstraints5 = MediaConstraints.KeyValuePair("setup", "actpass") - val mediaConstraints6 = MediaConstraints.KeyValuePair( - "video", - "true" - ) - val mediaConstraints = MediaConstraints() - - - mediaConstraints.mandatory.add(mediaConstraints1) - mediaConstraints.mandatory.add(mediaConstraints2) - mediaConstraints.mandatory.add(mediaConstraints3) - mediaConstraints.mandatory.add(mediaConstraints4) - mediaConstraints.mandatory.add(mediaConstraints5) - mediaConstraints.mandatory.add(mediaConstraints6) - - val audioSource = factory.createAudioSource(mediaConstraints) - - val audioTrack = factory.createAudioTrack("0001", audioSource) - track = factory.createVideoTrack("0001", videoSource) - - localPeer.addTrack(track, listOf("track01")) - - localPeer.addTrack(audioTrack, listOf("track01")) - } - fun changeCamera(){ - camera1Capturer.switchCamera(null) } - @OptIn(UnstableApi::class) - fun changeVideoSource(videoProcessor: VideoProcessor){ - //var frame = VideoFrame() - //videoSource.capturerObserver.onCapturerStarted(true) + fun joinRoom(room_id: String){ + var currentRoomClient = RoomClient(room_id, "david_android", socket, context = this.context) } - fun getVideoSource(): VideoSource { - return videoSource - } - fun createAndSendCallMessage(p0: SessionDescription?, userId: String, mediaID: String) { - var innerSDPMessage = JSONObject() - innerSDPMessage.put("sdp", p0!!.description) - innerSDPMessage.put("type", "offer") - var payloadMessage = JSONObject() - payloadMessage.put("connectionId", mediaID) - payloadMessage.put("type", "media") - payloadMessage.put("sdp", innerSDPMessage) - - var outerObjectMessage = JSONObject() - outerObjectMessage.put("dst", userId) - outerObjectMessage.put("src", ourID) - outerObjectMessage.put("payload", payloadMessage) - outerObjectMessage.put("type", "OFFER") - - webSocket.send(outerObjectMessage.toString()) + fun getAuthToken(){ + } + private var rooms: Array = emptyArray() + private var roomList = mutableListOf() + private lateinit var socket: Socket; - //function to begin calling - fun startCall(userId: String) { - theirID = userId - - val mediaConstraints1 = MediaConstraints.KeyValuePair( - "kRTCMediaConstraintsOfferToReceiveAudio", - "kRTCMediaConstraintsValueTrue" - ) - val mediaConstraints2 = MediaConstraints.KeyValuePair( - "kRTCMediaConstraintsOfferToReceiveVideo", - "kRTCMediaConstraintsValueTrue" - ) - val mediaConstraints3 = MediaConstraints.KeyValuePair( - "kRTCMediaStreamTrackKindVideo", - "kRTCMediaConstraintsValueTrue" - ) - - val mediaConstraints4 = MediaConstraints.KeyValuePair( - "DtlsSrtpKeyAgreement", - "kRTCMediaConstraintsValueTrue" - ) - - val mediaConstraints5 = MediaConstraints.KeyValuePair("setup", "actpass") - val mediaConstraints6 = MediaConstraints.KeyValuePair( - "video", - "true" - ) - val mediaConstraints = MediaConstraints() - - - mediaConstraints.mandatory.add(mediaConstraints1) - mediaConstraints.mandatory.add(mediaConstraints2) - mediaConstraints.mandatory.add(mediaConstraints3) - mediaConstraints.mandatory.add(mediaConstraints4) - mediaConstraints.mandatory.add(mediaConstraints5) - mediaConstraints.mandatory.add(mediaConstraints6) - mediaID = "31480asdf33" - sdpObserver = object: SdpObserver { - @OptIn(UnstableApi::class) - override fun onCreateSuccess(p0: SessionDescription?) { - Log.d("SignalingClient", "createSDP Success") - createAndSendCallMessage(p0, userId,mediaID) - var sdpObserver2 = object: SdpObserver { - override fun onCreateSuccess(p0: SessionDescription?) { - Log.d("SignalingClient", "onCreateSucess") - } - - - override fun onSetSuccess() { - Log.d("SignalingClient", "Set Peer Success") - isReadyToAddIceCandidate = true - } - - override fun onCreateFailure(p0: String?) { - //TODO("Not yet implemented") - } - - override fun onSetFailure(p0: String?) { - //TODO("Not yet implemented") - } - } - localPeer.setLocalDescription(sdpObserver2, p0) + @OptIn(UnstableApi::class) + fun getRoomList(){ + Log.d("SIGNALING CLIENT", "Attempting to emit getRoomList with Ack") + // 1. Prepare the payload (empty, as the server doesn't need it for this event) + val emptyPayload = JSONObject() // Or simply passing 'null' might work, but this is safer - } + // 2. Emit the event with two arguments: Payload + Ack Callback + socket.emit("getRoomList", emptyPayload, Ack { args -> - @OptIn(UnstableApi::class) - override fun onSetSuccess() { - Log.d("SignalingClient", "Remote SDP Call set Success") - } + // This block runs when the server executes 'callback(roomList)' - override fun onCreateFailure(p0: String?) { - //TODO("Not yet implemented") + if (args.isEmpty() || args[0] == null) { + Log.e("SIGNALING CLIENT", "No room list received.") + return@Ack } - override fun onSetFailure(p0: String?) { - //TODO("Not yet implemented") - } + // Assuming the room list is the first argument in the callback's arguments array + val responseData = args[0] - } + if (responseData is JSONArray) { - var offerSDP = localPeer.createOffer(sdpObserver, mediaConstraints) - //localPeer.setLocalDescription(sdpObserver) - } + Log.d("SIGNALING CLIENT", "SUCCESS! Room List received: $responseData") + // 1. Initialize a mutable list to hold the extracted room IDs - init { - buildVideoSenders(context, url) + var roomList = mutableListOf() - var videoCamera = VideoCameraSetup(context, localPeer, factory, rootEGL) - var sessionManager = SessionManager(context) - val authToken = sessionManager.fetchAuthToken() - - if (httpUrl != null) { - Log.d("SignalingClient", "URL IS $httpUrl") - Log.d("SignalingClient", "isHttps?: " + httpUrl) - val request = Request.Builder().url(httpUrl).addHeader("Authorization", - authToken.toString() - ).build() - Log.d("SignalingClient", "auth token" + authToken.toString()) - Log.d("SignalingClient", request.method) - - - // Create the WebSocket listener - webSocketListener = object : WebSocketListener() { - override fun onOpen(webSocket: WebSocket, response: Response) { - super.onOpen(webSocket, response) - Log.d("SignalingClient", "WebSocket opened: ${response.message}") + // 2. Loop through the JSONArray + for (i in 0 until responseData.length()) { + // 3. Safely extract each element as a String + val roomId = responseData.getString(i) + roomList.add(roomId) + onPeersFetched(roomList) } - override fun onMessage(webSocket: WebSocket, text: String) { - super.onMessage(webSocket, text) - Log.d("SignalingClient", "Message received: $text") - val jsonMessage = JSONObject(text) - if (text.contains("OFFER")) { - theirID = jsonMessage.get("src").toString() - onCallRecieved() - } - if (text.contains("OFFER") && text.contains("media")) { - - Log.d("SignalingClient", "We received an OFFER") - //val messageSplit = text.split(",") - Log.d("Signaling client", jsonMessage.get("payload").toString()) - theirID = jsonMessage.get("src").toString() - - Log.d("Signaling Client", "TheirID: $theirID") - val payload = jsonMessage.get("payload") as JSONObject - val sdpMessage = payload.get("sdp") as JSONObject - mediaID = payload.get("connectionId").toString() - Log.d("Signaling Client", "mediaID: $mediaID") - val theirSDP = sdpMessage.get("sdp").toString() - val sessionDescription = - SessionDescription(SessionDescription.Type.OFFER, theirSDP) - Log.d("signaling client", "sdp is: $theirSDP") - localPeer.setRemoteDescription(remoteObserver, sessionDescription) - - //Log.d("Signaling Client", "set remote SDP " + localPeer.toString()) - } - - if (text.contains("ANSWER") && text.contains("media")) { - - Log.d("SignalingClient", "We received an ANSWER") - //val messageSplit = text.split(",") - Log.d("Signaling client", jsonMessage.get("payload").toString()) - theirID = jsonMessage.get("src").toString() - - Log.d("Signaling Client", "TheirID: $theirID") - val payload = jsonMessage.get("payload") as JSONObject - val sdpMessage = payload.get("sdp") as JSONObject - mediaID = payload.get("connectionId").toString() - Log.d("Signaling Client", "mediaID: $mediaID") - val theirSDP = sdpMessage.get("sdp").toString() - val sessionDescription = - SessionDescription(SessionDescription.Type.ANSWER, theirSDP) - Log.d("signaling client", "sdp is: $theirSDP") - Log.d("SignalingClient", "Setting our reemote SDP To their answer") - localPeer.setRemoteDescription(sdpObserver, sessionDescription) - - //Log.d("Signaling Client", "set remote SDP " + localPeer.toString()) - } - - if (text.contains("CANDIDATE")) { - val payload = jsonMessage.get("payload") as JSONObject - Log.d("Payload Message", payload.toString()) - val candidateMsg = payload.get("candidate") as JSONObject - Log.d( - "CANDIDATE MESSAGE", - "Candidate variable contains: $candidateMsg" - ) - val sdpMid = candidateMsg.get("sdpMid").toString() - val sdpMLineIndex = candidateMsg.get("sdpMLineIndex").toString() - val candidate = candidateMsg.get("candidate").toString() - val iceCandidate = IceCandidate(sdpMid, sdpMLineIndex.toInt(), candidate) - - val msg = JSONObject() - msg.accumulate("type", "CANDIDATE") - msg.accumulate("payload", payload) - msg.accumulate("dst", theirID) - - - - if (!isReadyToAddIceCandidate) { - candidatesList.add(iceCandidate) - candidateMessagesToSend.add(msg.toString()) - Log.d("Signaling client", "Adding ICE CANDDIATE TO LIST") - } else { - Log.d( - "IceCANDIDATE", - localPeer.addIceCandidate(iceCandidate).toString() - ) - //webSocket.send(msg.toString()) - } - - - } - } - override fun onClosing(webSocket: WebSocket, code: Int, reason: String) { - super.onClosing(webSocket, code, reason) - Log.d("SignalingClient", "WebSocket closing: $reason (code $code)") - } - override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { - super.onClosed(webSocket, code, reason) - Log.d("SignalingClient", "WebSocket closed: $reason (code $code)") - } + // TODO: Update ViewModel/Activity state here - override fun onFailure( - webSocket: WebSocket, - t: Throwable, - response: Response? - ) { - super.onFailure(webSocket, t, response) - Log.e("SignalingClient", "WebSocket failure: ${t.message}") - } + } else { + Log.e("SIGNALING CLIENT", "Received unexpected response type: ${responseData.javaClass.name}") } - webSocket = client.newWebSocket(request, webSocketListener) + }) - } else { - Log.d("Signaling Client", "url is null") + } + + + init { + this.context = context + var sessionManager = SessionManager(context) + var token = sessionManager.fetchAuthToken() + Log.d("SIGNALING CLIENT", "token is " + token) + val authMap = mapOf("cookie" to listOf(token)) + + + + + val options = IO.Options.builder() + .setReconnection(true) + .setForceNew(true) + .setExtraHeaders(authMap) + + .build() + + + try{ + socket = IO.socket("https://meechie.techkit.xyz:3016", options) + + + }catch(e: Error){ + Log.d("SOCKET ERROR:" ,e.toString()) + } + + socket.connect() + fixedRateTimer("getRoomList timer", false, 0L, 175000L) { + getRoomList() } + } } \ No newline at end of file diff --git a/app/src/main/java/com/example/neurology_project_android/VideoCameraSetup.kt b/app/src/main/java/com/example/neurology_project_android/VideoCameraSetup.kt index ba39108..728dc12 100644 --- a/app/src/main/java/com/example/neurology_project_android/VideoCameraSetup.kt +++ b/app/src/main/java/com/example/neurology_project_android/VideoCameraSetup.kt @@ -1,167 +1,167 @@ -package com.example.neurology_project_android - -import android.annotation.SuppressLint -import android.content.Context -import android.hardware.usb.UsbDevice -import androidx.annotation.OptIn -import androidx.camera.core.imagecapture.CameraRequest -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import androidx.media3.common.util.Log -import androidx.media3.common.util.UnstableApi -import org.webrtc.CameraVideoCapturer -import org.webrtc.CapturerObserver -import org.webrtc.EglBase -import org.webrtc.NV21Buffer -import org.webrtc.PeerConnection -import org.webrtc.PeerConnectionFactory -import org.webrtc.SurfaceTextureHelper -import org.webrtc.VideoCapturer -import org.webrtc.VideoFrame -import org.webrtc.VideoProcessor -import org.webrtc.VideoSink -import org.webrtc.VideoSource -import java.nio.ByteBuffer - -class VideoCameraSetup constructor( - context: Context, - localPeer: PeerConnection, - factory: PeerConnectionFactory, - rootEGL1: EglBase -){ - - - @SuppressLint("RestrictedApi") - private lateinit var cameraRequest: CameraRequest - private lateinit var videoProcessor: VideoProcessor - private lateinit var videoSource: VideoSource - private lateinit var capturerObserver: CapturerObserver - private var rootEGL = rootEGL1 - - private var localPeer = localPeer - private var factory = factory - - private var cameraInitialized by mutableStateOf(false) - - init { - prepareVideoDevices(context) - - - } - - fun prepareVideoDevices(context: Context){ - - val videoCapturerObserver = object: CapturerObserver { - @OptIn(UnstableApi::class) - override fun onCapturerStarted(p0: Boolean) { - - Log.e("Capturer Observer", "Capture Started") - } - - override fun onCapturerStopped() { - TODO("Not yet implemented") - } - - override fun onFrameCaptured(p0: VideoFrame?) { - - - //Log.e("Capturer Observer", "Frame Captured") - } - - } - - - val videoCapturer = object: VideoCapturer { - @OptIn(UnstableApi::class) - override fun initialize( - p0: SurfaceTextureHelper?, - p1: Context?, - p2: CapturerObserver? - ) { - Log.e("Video Capturer", "Initialized") - } - - @OptIn(UnstableApi::class) - override fun startCapture(p0: Int, p1: Int, p2: Int) { - Log.e("VIDEO CAPTURER" , "Start Capture") - - } - - override fun stopCapture() { - TODO("Not yet implemented") - } - - override fun changeCaptureFormat( - p0: Int, - p1: Int, - p2: Int - ) { - TODO("Not yet implemented") - } - - override fun dispose() { - TODO("Not yet implemented") - } - - override fun isScreencast(): Boolean { - TODO("Not yet implemented") - } - - } - - videoProcessor = object: VideoProcessor { - override fun onCapturerStarted(p0: Boolean) { - TODO("Not yet implemented") - } - - override fun onCapturerStopped() { - TODO("Not yet implemented") - } - - override fun onFrameCaptured(p0: VideoFrame?) { - TODO("Not yet implemented") - } - - override fun setSink(p0: VideoSink?) { - TODO("Not yet implemented") - } - - } - - var timeStampNS: Long - var n21Buffer: NV21Buffer - var videoFrame: VideoFrame - - - - sendVideoCapturer(videoCapturer, context, videoCapturerObserver, localPeer, factory ) - } - - - fun sendVideoCapturer(capturer: VideoCapturer, context: Context, capturerObserver: CapturerObserver, localPeer: PeerConnection, factory: PeerConnectionFactory) { - - //localPeer = factory.createPeerConnection(config, peerConnObserver)!! - - - val videoSource2 = factory.createVideoSource(true) - val surfaceTexture = SurfaceTextureHelper.create("CaptureThread", rootEGL.eglBaseContext) - //capturerObserver.onCapturerStarted(true) - //capturer.initialize(surfaceTexture,context , capturerObserver) - - - //videoSource2.setVideoProcessor(videoProcessor) - //videoProcessor.onCapturerStarted(true) - //capturer.startCapture(1080, 720, 30) - - var videoTrack = factory.createVideoTrack("0001", videoSource2) - //videoSource = videoSource2 - - //localPeer.addTrack(videoTrack, listOf("track01")) - - - - //camera.initialize(surfaceTexture,context , capturerObserver) - - } -} \ No newline at end of file +//package com.example.neurology_project_android +// +//import android.annotation.SuppressLint +//import android.content.Context +//import android.hardware.usb.UsbDevice +//import androidx.annotation.OptIn +//import androidx.camera.core.imagecapture.CameraRequest +//import androidx.compose.runtime.getValue +//import androidx.compose.runtime.mutableStateOf +//import androidx.compose.runtime.setValue +//import androidx.media3.common.util.Log +//import androidx.media3.common.util.UnstableApi +//import org.webrtc.CameraVideoCapturer +//import org.webrtc.CapturerObserver +//import org.webrtc.EglBase +//import org.webrtc.NV21Buffer +//import org.webrtc.PeerConnection +//import org.webrtc.PeerConnectionFactory +//import org.webrtc.SurfaceTextureHelper +//import org.webrtc.VideoCapturer +//import org.webrtc.VideoFrame +//import org.webrtc.VideoProcessor +//import org.webrtc.VideoSink +//import org.webrtc.VideoSource +//import java.nio.ByteBuffer +// +//class VideoCameraSetup constructor( +// context: Context, +// localPeer: PeerConnection, +// factory: PeerConnectionFactory, +// rootEGL1: EglBase +//){ +// +// +// @SuppressLint("RestrictedApi") +// private lateinit var cameraRequest: CameraRequest +// private lateinit var videoProcessor: VideoProcessor +// private lateinit var videoSource: VideoSource +// private lateinit var capturerObserver: CapturerObserver +// private var rootEGL = rootEGL1 +// +// private var localPeer = localPeer +// private var factory = factory +// +// private var cameraInitialized by mutableStateOf(false) +// +// init { +// prepareVideoDevices(context) +// +// +// } +// +// fun prepareVideoDevices(context: Context){ +// +// val videoCapturerObserver = object: CapturerObserver { +// @OptIn(UnstableApi::class) +// override fun onCapturerStarted(p0: Boolean) { +// +// Log.e("Capturer Observer", "Capture Started") +// } +// +// override fun onCapturerStopped() { +// TODO("Not yet implemented") +// } +// +// override fun onFrameCaptured(p0: VideoFrame?) { +// +// +// //Log.e("Capturer Observer", "Frame Captured") +// } +// +// } +// +// +// val videoCapturer = object: VideoCapturer { +// @OptIn(UnstableApi::class) +// override fun initialize( +// p0: SurfaceTextureHelper?, +// p1: Context?, +// p2: CapturerObserver? +// ) { +// Log.e("Video Capturer", "Initialized") +// } +// +// @OptIn(UnstableApi::class) +// override fun startCapture(p0: Int, p1: Int, p2: Int) { +// Log.e("VIDEO CAPTURER" , "Start Capture") +// +// } +// +// override fun stopCapture() { +// TODO("Not yet implemented") +// } +// +// override fun changeCaptureFormat( +// p0: Int, +// p1: Int, +// p2: Int +// ) { +// TODO("Not yet implemented") +// } +// +// override fun dispose() { +// TODO("Not yet implemented") +// } +// +// override fun isScreencast(): Boolean { +// TODO("Not yet implemented") +// } +// +// } +// +// videoProcessor = object: VideoProcessor { +// override fun onCapturerStarted(p0: Boolean) { +// TODO("Not yet implemented") +// } +// +// override fun onCapturerStopped() { +// TODO("Not yet implemented") +// } +// +// override fun onFrameCaptured(p0: VideoFrame?) { +// TODO("Not yet implemented") +// } +// +// override fun setSink(p0: VideoSink?) { +// TODO("Not yet implemented") +// } +// +// } +// +// var timeStampNS: Long +// var n21Buffer: NV21Buffer +// var videoFrame: VideoFrame +// +// +// +// sendVideoCapturer(videoCapturer, context, videoCapturerObserver, localPeer, factory ) +// } +// +// +// fun sendVideoCapturer(capturer: VideoCapturer, context: Context, capturerObserver: CapturerObserver, localPeer: PeerConnection, factory: PeerConnectionFactory) { +// +// //localPeer = factory.createPeerConnection(config, peerConnObserver)!! +// +// +// val videoSource2 = factory.createVideoSource(true) +// val surfaceTexture = SurfaceTextureHelper.create("CaptureThread", rootEGL.eglBaseContext) +// //capturerObserver.onCapturerStarted(true) +// //capturer.initialize(surfaceTexture,context , capturerObserver) +// +// +// //videoSource2.setVideoProcessor(videoProcessor) +// //videoProcessor.onCapturerStarted(true) +// //capturer.startCapture(1080, 720, 30) +// +// var videoTrack = factory.createVideoTrack("0001", videoSource2) +// //videoSource = videoSource2 +// +// //localPeer.addTrack(videoTrack, listOf("track01")) +// +// +// +// //camera.initialize(surfaceTexture,context , capturerObserver) +// +// } +//} \ No newline at end of file diff --git a/app/src/main/java/com/example/neurology_project_android/sampledata/Consumer.java b/app/src/main/java/com/example/neurology_project_android/sampledata/Consumer.java new file mode 100644 index 0000000..f4f2e90 --- /dev/null +++ b/app/src/main/java/com/example/neurology_project_android/sampledata/Consumer.java @@ -0,0 +1,35 @@ +package com.example.neurology_project_android.sampledata; + +import org.webrtc.MediaStreamTrack; +import org.webrtc.RtpReceiver; + +import java.util.Objects; + +class Consumer { + String id; + String mid; + String peerId; + Engine.MediaKind kind; + + MediaStreamTrack track; + Consumer(String id, String mid){ + id = id; + mid = mid; + } + + public void setTrack(MediaStreamTrack track) { + this.track = track; + + if (Objects.equals(track.kind(), "audio")){ + kind = Engine.MediaKind.Audio; + } else { + kind = Engine.MediaKind.Video; + } + } + + + + public void setPeerId(String peerId) { + this.peerId = peerId; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/neurology_project_android/sampledata/Device.java b/app/src/main/java/com/example/neurology_project_android/sampledata/Device.java new file mode 100644 index 0000000..2b041e2 --- /dev/null +++ b/app/src/main/java/com/example/neurology_project_android/sampledata/Device.java @@ -0,0 +1,476 @@ +package com.example.neurology_project_android.sampledata; + +import android.content.Context; +import android.util.Log; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; + +import org.webrtc.AudioSource; +import org.webrtc.AudioTrack; +import org.webrtc.CandidatePairChangeEvent; +import org.webrtc.DataChannel; +import org.webrtc.EglBase; +import org.webrtc.IceCandidate; +import org.webrtc.IceCandidateErrorEvent; +import org.webrtc.MediaConstraints; +import org.webrtc.MediaStream; +import org.webrtc.PeerConnection; +import org.webrtc.PeerConnectionFactory; +import org.webrtc.RtpReceiver; +import org.webrtc.SdpObserver; +import org.webrtc.SessionDescription; +import org.webrtc.SoftwareVideoDecoderFactory; +import org.webrtc.SoftwareVideoEncoderFactory; +import org.webrtc.VideoDecoderFactory; +import org.webrtc.VideoEncoderFactory; +import org.webrtc.VideoSource; +import org.webrtc.VideoTrack; +import org.webrtc.audio.AudioDeviceModule; +import org.webrtc.audio.JavaAudioDeviceModule; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.Callable; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; + +import one.dugon.mediasoup_android_sdk.sdp.Parser; +import one.dugon.mediasoup_android_sdk.sdp.Utils; + + +public class Device { + + private static final String TAG = "Dugon"; + + public static final String AUDIO_TRACK_ID = "ARDAMSa0"; + public static final String VIDEO_TRACK_ID = "ARDAMSv0"; + + public static final ExecutorService executor = Executors.newSingleThreadExecutor(); + + private static Context appContext; + + private static EglBase rootEglBase; + + private static PeerConnectionFactory factory; + + public static void initialize(Context context) { + appContext = context; + + rootEglBase = EglBase.create(); + + // TODO: 2024/9/30 log level + executor.execute(() -> { + +// Log.d(TAG, "Initialize WebRTC. Field trials: " + fieldTrials); + PeerConnectionFactory.initialize( + PeerConnectionFactory.InitializationOptions.builder(appContext) +// .setFieldTrials(fieldTrials) + .setEnableInternalTracer(true) + .createInitializationOptions()); + + + // PeerConnectionFactory + final AudioDeviceModule adm = createJavaAudioDevice(); + + // Create peer connection factory. + PeerConnectionFactory.Options options = new PeerConnectionFactory.Options(); + +// final boolean enableH264HighProfile = +// VIDEO_CODEC_H264_HIGH.equals(peerConnectionParameters.videoCodec); + final VideoEncoderFactory encoderFactory; + final VideoDecoderFactory decoderFactory; + + +// encoderFactory = new DefaultVideoEncoderFactory( +// rootEglBase.getEglBaseContext(), true /* enableIntelVp8Encoder */, enableH264HighProfile); +// decoderFactory = new DefaultVideoDecoderFactory(rootEglBase.getEglBaseContext()); + + encoderFactory = new SoftwareVideoEncoderFactory(); + decoderFactory = new SoftwareVideoDecoderFactory(); + + // Disable encryption for loopback calls. +// if (peerConnectionParameters.loopback) { +// options.disableEncryption = true; +// } + + factory = PeerConnectionFactory.builder() + .setOptions(options) + .setAudioDeviceModule(adm) + .setVideoEncoderFactory(encoderFactory) + .setVideoDecoderFactory(decoderFactory) + .createPeerConnectionFactory(); + Log.d(TAG, "Peer connection factory created."); + adm.release(); + }); + } + + public static JsonObject rtpCapabilities; + + public static JsonObject sctpCapabilities; + + public static JsonObject extendedRtpCapabilities; + + public static JsonObject getRtpCapabilities() { + CompletableFuture futureDesc = new CompletableFuture<>(); + + Callable task = () -> { + Log.d(TAG, "getRtpCapabilities"); + + List iceServers = new ArrayList<>(); + + PeerConnection.RTCConfiguration rtcConfig = + new PeerConnection.RTCConfiguration(iceServers); + // TCP candidates are only useful when connecting to a server that supports + // ICE-TCP. + rtcConfig.tcpCandidatePolicy = PeerConnection.TcpCandidatePolicy.DISABLED; + rtcConfig.bundlePolicy = PeerConnection.BundlePolicy.MAXBUNDLE; + rtcConfig.rtcpMuxPolicy = PeerConnection.RtcpMuxPolicy.REQUIRE; + rtcConfig.continualGatheringPolicy = PeerConnection.ContinualGatheringPolicy.GATHER_CONTINUALLY; + // Use ECDSA encryption. + rtcConfig.keyType = PeerConnection.KeyType.ECDSA; + rtcConfig.sdpSemantics = PeerConnection.SdpSemantics.UNIFIED_PLAN; + + assert factory != null; + PCObserverForRtpCaps pcObserver = new PCObserverForRtpCaps(); + PeerConnection peerConnection = factory.createPeerConnection(rtcConfig, pcObserver); + + MediaConstraints sdpMediaConstraints = new MediaConstraints(); + + sdpMediaConstraints.mandatory.add( + new MediaConstraints.KeyValuePair("OfferToReceiveAudio", "true")); + sdpMediaConstraints.mandatory.add( + new MediaConstraints.KeyValuePair("OfferToReceiveVideo", "true")); + + SDPObserverForRtpCaps sdpObserver = new SDPObserverForRtpCaps() { + @Override + public void onCreateSuccess(SessionDescription desc) { +// super.onCreateSuccess(desc); + Log.d("w", "onCreateSuccess"); + futureDesc.complete(desc); + + } + + @Override + public void onCreateFailure(String error) { +// super.onCreateFailure(error); + futureDesc.completeExceptionally(new Exception(error)); + + } + + }; + assert peerConnection != null; + peerConnection.createOffer(sdpObserver, sdpMediaConstraints); + + return 1; + }; + + executor.submit(task); + + try { + SessionDescription sdp = futureDesc.get(); + + JsonObject sdpSession = Parser.parse(sdp.description); +// var sdpStr = Writer.write(sdpSession); + JsonObject rtpCapabilities = Utils.extractRtpCapabilities(sdpSession); + Log.d("W", rtpCapabilities.toString()); +// Log.d("W",sdp.description); + return rtpCapabilities; + + } catch (Exception e) { + e.printStackTrace(); + } + return null; + } + + public static LocalVideoSource createVideoSource() { + + Callable task = () -> { + boolean isScreencast = false; + + VideoSource videoSource = factory.createVideoSource(isScreencast); + + VideoTrack localVideoTrack = factory.createVideoTrack(VIDEO_TRACK_ID, videoSource); + localVideoTrack.setEnabled(true); + + return new LocalVideoSource(appContext, rootEglBase, videoSource, localVideoTrack); + }; + + Future future = executor.submit(task); + + try { + return future.get(); + } catch (Exception e) { +// e.printStackTrace(); + } + return null; + } + + public static LocalAudioSource createAudioSource() { + Callable task = () -> { + + MediaConstraints audioConstraints = new MediaConstraints();; + + AudioSource audioSource = factory.createAudioSource(audioConstraints); + AudioTrack localAudioTrack = factory.createAudioTrack(AUDIO_TRACK_ID, audioSource); + + localAudioTrack.setEnabled(true); + + return new LocalAudioSource(audioSource, localAudioTrack); + }; + + Future future = executor.submit(task); + + try { + return future.get(); + } catch (Exception e) { +// e.printStackTrace(); + } + return null; + } + + static AudioDeviceModule createJavaAudioDevice() { + // Enable/disable OpenSL ES playback. +// if (!peerConnectionParameters.useOpenSLES) { +// Log.w(TAG, "External OpenSLES ADM not implemented yet."); +// // TODO(magjed): Add support for external OpenSLES ADM. +// } + + // Set audio record error callbacks. + JavaAudioDeviceModule.AudioRecordErrorCallback audioRecordErrorCallback = new JavaAudioDeviceModule.AudioRecordErrorCallback() { + @Override + public void onWebRtcAudioRecordInitError(String errorMessage) { + Log.e(TAG, "onWebRtcAudioRecordInitError: " + errorMessage); +// reportError(errorMessage); + } + + @Override + public void onWebRtcAudioRecordStartError( + JavaAudioDeviceModule.AudioRecordStartErrorCode errorCode, String errorMessage) { + Log.e(TAG, "onWebRtcAudioRecordStartError: " + errorCode + ". " + errorMessage); +// reportError(errorMessage); + } + + @Override + public void onWebRtcAudioRecordError(String errorMessage) { + Log.e(TAG, "onWebRtcAudioRecordError: " + errorMessage); +// reportError(errorMessage); + } + }; + + JavaAudioDeviceModule.AudioTrackErrorCallback audioTrackErrorCallback = new JavaAudioDeviceModule.AudioTrackErrorCallback() { + @Override + public void onWebRtcAudioTrackInitError(String errorMessage) { + Log.e(TAG, "onWebRtcAudioTrackInitError: " + errorMessage); +// reportError(errorMessage); + } + + @Override + public void onWebRtcAudioTrackStartError( + JavaAudioDeviceModule.AudioTrackStartErrorCode errorCode, String errorMessage) { + Log.e(TAG, "onWebRtcAudioTrackStartError: " + errorCode + ". " + errorMessage); +// reportError(errorMessage); + } + + @Override + public void onWebRtcAudioTrackError(String errorMessage) { + Log.e(TAG, "onWebRtcAudioTrackError: " + errorMessage); +// reportError(errorMessage); + } + }; + + // Set audio record state callbacks. + JavaAudioDeviceModule.AudioRecordStateCallback audioRecordStateCallback = new JavaAudioDeviceModule.AudioRecordStateCallback() { + @Override + public void onWebRtcAudioRecordStart() { + Log.i(TAG, "Audio recording starts"); + } + + @Override + public void onWebRtcAudioRecordStop() { + Log.i(TAG, "Audio recording stops"); + } + }; + + // Set audio track state callbacks. + JavaAudioDeviceModule.AudioTrackStateCallback audioTrackStateCallback = new JavaAudioDeviceModule.AudioTrackStateCallback() { + @Override + public void onWebRtcAudioTrackStart() { + Log.i(TAG, "Audio playout starts"); + } + + @Override + public void onWebRtcAudioTrackStop() { + Log.i(TAG, "Audio playout stops"); + } + }; + + return JavaAudioDeviceModule.builder(appContext) +// .setSamplesReadyCallback(saveRecordedAudioToFile) +// .setUseHardwareAcousticEchoCanceler(!peerConnectionParameters.disableBuiltInAEC) +// .setUseHardwareNoiseSuppressor(!peerConnectionParameters.disableBuiltInNS) + .setAudioRecordErrorCallback(audioRecordErrorCallback) + .setAudioTrackErrorCallback(audioTrackErrorCallback) + .setAudioRecordStateCallback(audioRecordStateCallback) + .setAudioTrackStateCallback(audioTrackStateCallback) + .createAudioDeviceModule(); + } + + public static void initView(Player player) { + player.initEgl(rootEglBase.getEglBaseContext()); + } + + + static class PCObserverForRtpCaps implements PeerConnection.Observer { + @Override + public void onIceCandidate(final IceCandidate candidate) { + } + + @Override + public void onIceCandidateError(final IceCandidateErrorEvent event) { + } + + @Override + public void onIceCandidatesRemoved(final IceCandidate[] candidates) { + } + + @Override + public void onSignalingChange(PeerConnection.SignalingState newState) { + } + + @Override + public void onIceConnectionChange(final PeerConnection.IceConnectionState newState) { + } + + @Override + public void onConnectionChange(final PeerConnection.PeerConnectionState newState) { + } + + @Override + public void onIceGatheringChange(PeerConnection.IceGatheringState newState) { + } + + @Override + public void onIceConnectionReceivingChange(boolean receiving) { + } + + @Override + public void onSelectedCandidatePairChanged(CandidatePairChangeEvent event) { + } + + @Override + public void onAddStream(final MediaStream stream) { + } + + @Override + public void onRemoveStream(final MediaStream stream) { + } + + @Override + public void onDataChannel(final DataChannel dc) { + } + + @Override + public void onRenegotiationNeeded() { + } + + @Override + public void onAddTrack(final RtpReceiver receiver, final MediaStream[] mediaStreams) { + } + + @Override + public void onRemoveTrack(final RtpReceiver receiver) { + } + } + + static class SDPObserverForRtpCaps implements SdpObserver { + @Override + public void onCreateSuccess(final SessionDescription desc) { + } + + @Override + public void onCreateFailure(final String error) { + } + + @Override + public void onSetSuccess() { + } + + @Override + public void onSetFailure(final String error) { + } + } + + // for mediasoup + public static void load(JsonObject routerRtpCapabilities) { + Log.d(TAG, "getRtpCapabilities 1"); + + JsonObject numStreams = new JsonObject(); + numStreams.addProperty("OS", "1024"); + numStreams.addProperty("MIS", "1024"); + + sctpCapabilities = new JsonObject(); + sctpCapabilities.add("numStreams", numStreams); + + Log.d(TAG, "getRtpCapabilities 2"); + + JsonObject local = getRtpCapabilities(); + Log.d(TAG, "getRtpCapabilities ok"); + extendedRtpCapabilities = Utils.getExtendedRtpCapabilities(local, routerRtpCapabilities); + Log.d(TAG, extendedRtpCapabilities.toString()); + rtpCapabilities = Utils.getRecvRtpCapabilities(extendedRtpCapabilities); + } + + public static SendTransport createSendTransport( + String id, + JsonObject iceParameters, + JsonArray iceCandidates, + JsonObject dtlsParameters + ) { + JsonObject audioSendingRtpParameters = Utils.getSendingRtpParameters("audio", extendedRtpCapabilities); + JsonObject videoSendingRtpParameters = Utils.getSendingRtpParameters("video", extendedRtpCapabilities); + JsonObject sendingRtpParametersByKind = new JsonObject(); + sendingRtpParametersByKind.add("audio", audioSendingRtpParameters); + sendingRtpParametersByKind.add("video", videoSendingRtpParameters); + + + JsonObject audioSendingRemoteRtpParameters = Utils.getSendingRemoteRtpParameters("audio", extendedRtpCapabilities); + JsonObject videoSendingRemoteRtpParameters = Utils.getSendingRemoteRtpParameters("video", extendedRtpCapabilities); + JsonObject sendingRemoteRtpParametersByKind = new JsonObject(); + sendingRemoteRtpParametersByKind.add("audio", audioSendingRemoteRtpParameters); + sendingRemoteRtpParametersByKind.add("video", videoSendingRemoteRtpParameters); + + SendTransport t = new SendTransport(id, iceParameters, iceCandidates, dtlsParameters, sendingRtpParametersByKind, sendingRemoteRtpParametersByKind); + // + List iceServers = new ArrayList<>(); + + PeerConnection.RTCConfiguration rtcConfig = + new PeerConnection.RTCConfiguration(iceServers); + + PeerConnection peerConnection = factory.createPeerConnection(rtcConfig, t.transport); + t.start(peerConnection); + return t; + } + + public static RecvTransport createRecvTransport( + String id, + JsonObject iceParameters, + JsonArray iceCandidates, + JsonObject dtlsParameters + ) { + RecvTransport t = new RecvTransport(id,iceParameters,iceCandidates,dtlsParameters); + // + List iceServers = new ArrayList<>(); + + PeerConnection.RTCConfiguration rtcConfig = + new PeerConnection.RTCConfiguration(iceServers); + + rtcConfig.bundlePolicy = PeerConnection.BundlePolicy.MAXBUNDLE; + + PeerConnection peerConnection = factory.createPeerConnection(rtcConfig, t.transport); + t.transport.start(peerConnection); + return t; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/neurology_project_android/sampledata/Engine.java b/app/src/main/java/com/example/neurology_project_android/sampledata/Engine.java new file mode 100644 index 0000000..9d3c97c --- /dev/null +++ b/app/src/main/java/com/example/neurology_project_android/sampledata/Engine.java @@ -0,0 +1,314 @@ +package com.example.neurology_project_android.sampledata; + +import android.content.Context; +import android.util.Log; + +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.reflect.TypeToken; + +import org.json.JSONException; +import org.json.JSONObject; +import org.webrtc.MediaStreamTrack; +import org.webrtc.RtpReceiver; +import org.webrtc.VideoTrack; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.function.BiConsumer; +import java.util.function.Consumer; +import java.util.function.Function; + +import one.dugon.mediasoup_android_sdk.protoo.ProtooEventListener; +import one.dugon.mediasoup_android_sdk.protoo.ProtooSocket; + + +public class Engine { + + public enum PeerState { + Join, + Leave, + } + + public enum MediaKind { + Audio, + Video, + } + + public interface Listener { + void onPeer(String peerId, PeerState state); + void onMedia(String peerId, String consumerId , MediaKind kind, boolean available); + } + + private static final String TAG = "Engine"; + + private Listener listener; + + private ProtooSocket protoo; + private SendTransport sendTransport; + private RecvTransport recvTransport; + + private LocalAudioSource localAudioSource; + private LocalVideoSource localVideoSource; + + private static final ExecutorService executor = Executors.newSingleThreadExecutor(); + + public Consumer onTrack = null; + + List peerList; + + HashMap consumerHashMap; + HashMap tracks; + + + public Engine(Context context){ + protoo = new ProtooSocket(); + consumerHashMap = new HashMap<>(); + tracks = new HashMap<>(); + Device.initialize(context); + } + + public void setListener(Listener listener){ + this.listener = listener; + } + + public void connect(String signalServer, String roomId, String peerId){ + + protoo.setEventListener(new ProtooEventListener() { + @Override + public void onConnect() { + Log.d(TAG, "onConnect"); + + executor.execute(()->{ + getRtpCaps(); + createWebRTCTransport(false); + createWebRTCTransport(true); + join(); + }); + } + + @Override + public void onDisconnect() { + + } + + @Override + public void onRequest(JsonObject requestData) { + Log.d(TAG,"onRequest:"); + String requestMethod = requestData.get("method").getAsString(); + if (Objects.equals(requestMethod, "newConsumer")) { + + Log.d(TAG,"newConsumer"); + int id = requestData.get("id").getAsInt(); + JsonObject data = requestData.get("data").getAsJsonObject(); + String peerId = data.get("peerId").getAsString(); + + String kind = data.get("kind").getAsString(); + String consumerId = data.get("id").getAsString(); + JsonObject rtpParameters= data.get("rtpParameters").getAsJsonObject(); + + + Device.executor.execute(()->{ + com.example.neurology_project_android.sampledata.Consumer consumer = recvTransport.receive(consumerId,kind,rtpParameters); + consumer.setPeerId(peerId); + + MediaStreamTrack track = tracks.get(consumerId); + + MediaKind k; + if (Objects.equals(track.kind(), "audio")){ + k = Engine.MediaKind.Audio; + } else { + k = Engine.MediaKind.Video; + } + + consumer.setTrack(track); + consumerHashMap.put(consumerId, consumer); + + listener.onMedia(peerId, consumerId, k, true); +// if(kind.equals("video")){ +// Log.d(TAG,"video !" + transceiver.getMid()); +// var track = transceiver.getReceiver().track(); +// var videotrack = (VideoTrack)track; +// videotrack.setEnabled(true); +// +// myVideoTrack = videotrack; +// addRemoteVideoRenderer(videotrack); +// } + + protoo.response(id); + }); + + + } + } + + @Override + public void onNotification(String method, JsonObject data) { + if(Objects.equals(method, "newPeer")){ + Gson gson = new Gson(); + Peer peer = gson.fromJson(data, new TypeToken(){}.getType()); + peerList.add(peer); + listener.onPeer(peer.id, PeerState.Join); + } else if(Objects.equals(method, "peerClosed")){ + String peerId = data.get("peerId").getAsString(); + peerList.removeIf(peer -> peer.id.equals(peerId)); + listener.onPeer(peerId, PeerState.Leave); + } + } + + @Override + public void onError() { + + } + }); + + protoo.connect(signalServer, Map.of("roomId", roomId, "peerId", peerId)); + } + + private void getRtpCaps(){ + JsonObject response = protoo.requestSync("getRouterRtpCapabilities"); + Device.load(response); + } + + private void createWebRTCTransport(boolean isSender){ + JsonObject createData = new JsonObject(); + createData.addProperty("consuming", !isSender); + createData.addProperty("forceTcp", false); + createData.addProperty("producing", isSender); + + JsonObject response = protoo.requestSync("createWebRtcTransport", createData); + + String id = response.get("id").getAsString(); + JsonObject iceParameters = response.getAsJsonObject("iceParameters"); + JsonArray iceCandidates = response.getAsJsonArray("iceCandidates"); + JsonObject dtlsParameters = response.getAsJsonObject("dtlsParameters"); + + if (isSender){ + sendTransport = Device.createSendTransport(id, iceParameters, iceCandidates, dtlsParameters); + +// sendTransport.onConnect = (JSONObject dtls)->{ +// Log.d(TAG,"dtls:"+dtls.toString()); +// JsonObject connectData = new JsonObject(); +// connectData.addProperty("transportId", id); +// //connectData.add("dtlsParameters",dtls); +//// var r4 = socket.request("connectWebRtcTransport", connectData); +// //JsonObject connectResponse = protoo.requestSync("connectWebRtcTransport", connectData); +// Log.d(TAG,"dtls: ok"); +// }; + +// sendTransport.onProduce = (JsonObject pData)->{ +// Log.d(TAG, "onProduce:"); +// pData.addProperty("transportId",id); +// JsonObject produceResponse = protoo.requestSync("produce", pData); +// return produceResponse.get("id").getAsString(); +// }; + + }else{ + recvTransport = Device.createRecvTransport(id,iceParameters,iceCandidates,dtlsParameters); + recvTransport.onTrack = (MediaStreamTrack track)->{ +// String kind = track.kind(); +// if(kind.equals("video")){ +// var videotrack = (VideoTrack)track; +// videotrack.setEnabled(true); +// +//// myVideoTrack = videotrack; +// addRemoteVideoRenderer(videotrack); +// } + tracks.put(track.id(),track); + }; + + + recvTransport.onConnect = (JSONObject dtls)->{ + Log.d(TAG,"recv dtls:"+dtls.toString()); + JsonObject connectData = new JsonObject(); + connectData.addProperty("transportId", id); + //connectData.add("dtlsParameters",dtls); +// var r4 = socket.request("connectWebRtcTransport", connectData); + JsonObject connectResponse = protoo.requestSync("connectWebRtcTransport", connectData); + Log.d(TAG,"dtls: ok"); + }; + +// recvTransport.onTrack = (String trackId)-> { +// if(onTrack != null){ +// onTrack.accept(trackId); +// } +// }; +// +// recvTransport.onTrack = (RtpReceiver receiver) ->{ +// Log.d(TAG,"Fuck " + receiver.track().id()); +// +// receivers.put(receiver.track().id(), receiver); +// }; + + } + + } + + private void join(){ + JsonObject joinData = new JsonObject(); + JsonObject rtpCapabilitiesJson = Device.rtpCapabilities; + JsonObject sctpCapabilitiesJson = Device.sctpCapabilities; + + JsonObject device = new JsonObject(); + device.addProperty("flag", "chrome"); + device.addProperty("name", "Chrome"); + device.addProperty("version", "129.0.0.0"); + + joinData.add("device", device); + joinData.add("rtpCapabilities", rtpCapabilitiesJson); + joinData.add("sctpCapabilities", sctpCapabilitiesJson); + joinData.addProperty("displayName", "gg"); + + JsonObject response = protoo.requestSync("join", joinData); + // TODO: 2025/3/16 handle response + JsonArray peers = response.get("peers").getAsJsonArray(); +// for (JsonElement p : peers) { +// Log.d(TAG,"peer"); +// +// } + // TODO: 2025/8/14 reuse gson + Gson gson = new Gson(); + peerList = gson.fromJson(peers, new TypeToken>(){}.getType()); + for(Peer p: peerList ){ + Log.d(TAG, "peer join " + p.id); + listener.onPeer(p.id, PeerState.Join); + } + } + + public void enableCam() throws JSONException { + localVideoSource = Device.createVideoSource(); +// sendTransport.abc(); + sendTransport.send(localVideoSource); + } + + public void enableMic() throws JSONException { + localAudioSource = Device.createAudioSource(); + sendTransport.send(localAudioSource); + } + + public void previewCam(Player player){ + player.play(localVideoSource); + } + + public void initView(Player player){ + Device.initView(player); + } + + public void play(Player player, String consumerId){ + com.example.neurology_project_android.sampledata.Consumer consumer = consumerHashMap.get(consumerId); + + if(consumer.kind == MediaKind.Video){ + Log.d(TAG,"Play " + consumerId); + + VideoTrack videoTrack = (VideoTrack) consumer.track; + player.play(videoTrack); + } + + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/neurology_project_android/sampledata/LocalAudioSource.java b/app/src/main/java/com/example/neurology_project_android/sampledata/LocalAudioSource.java new file mode 100644 index 0000000..57554df --- /dev/null +++ b/app/src/main/java/com/example/neurology_project_android/sampledata/LocalAudioSource.java @@ -0,0 +1,25 @@ +package com.example.neurology_project_android.sampledata; + +import org.webrtc.AudioSource; +import org.webrtc.AudioTrack; +import org.webrtc.MediaStreamTrack; + +public class LocalAudioSource extends LocalSource{ + private AudioTrack track; + private AudioSource source; + + public LocalAudioSource(AudioSource audioSource, AudioTrack audioTrack) { + source = audioSource; + track = audioTrack; + } + + @Override + public MediaStreamTrack getTrack() { + return track; + } + + @Override + public String getKind() { + return "audio"; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/neurology_project_android/sampledata/LocalSource.java b/app/src/main/java/com/example/neurology_project_android/sampledata/LocalSource.java new file mode 100644 index 0000000..accb5d0 --- /dev/null +++ b/app/src/main/java/com/example/neurology_project_android/sampledata/LocalSource.java @@ -0,0 +1,16 @@ +package com.example.neurology_project_android.sampledata; // <-- Must be your package! + + +import org.webrtc.MediaStreamTrack; + +// FIX 1: Make the class public so your RoomClient can inherit/reference it. +public abstract class LocalSource { + + // FIX 2 (Crucial): The abstract methods must be public + // if you want external classes to rely on them. + public abstract MediaStreamTrack getTrack(); + public abstract String getKind(); + + + // ... other abstract methods ... +} \ No newline at end of file diff --git a/app/src/main/java/com/example/neurology_project_android/sampledata/LocalVideoSource.java b/app/src/main/java/com/example/neurology_project_android/sampledata/LocalVideoSource.java new file mode 100644 index 0000000..5a561d2 --- /dev/null +++ b/app/src/main/java/com/example/neurology_project_android/sampledata/LocalVideoSource.java @@ -0,0 +1,86 @@ +package com.example.neurology_project_android.sampledata; + +import android.content.Context; + +import androidx.annotation.Nullable; + +import org.webrtc.Camera2Enumerator; +import org.webrtc.CameraEnumerator; +import org.webrtc.EglBase; +import org.webrtc.Logging; +import org.webrtc.MediaStreamTrack; +import org.webrtc.SurfaceTextureHelper; +import org.webrtc.VideoCapturer; +import org.webrtc.VideoSource; +import org.webrtc.VideoTrack; + +public class LocalVideoSource extends LocalSource{ + + private static final String TAG = "LocalVideoSource"; + + private VideoCapturer capturer; + private VideoSource source; + private final Context appContext; + private SurfaceTextureHelper surfaceTextureHelper; + + public VideoTrack track; + + public LocalVideoSource(Context context, EglBase rootEglBase, VideoSource videoSource, VideoTrack videoTrack){ + appContext = context; + source = videoSource; + track = videoTrack; + // + capturer = createCameraCapturer(new Camera2Enumerator(appContext)); + surfaceTextureHelper = + SurfaceTextureHelper.create("CaptureThread", rootEglBase.getEglBaseContext()); + capturer.initialize(surfaceTextureHelper, appContext, source.getCapturerObserver()); + capturer.startCapture(720, 1280, 30); + } + + @Override + public MediaStreamTrack getTrack() { + return track; + } + + @Override + public String getKind() { + return "video"; + } + + + // public void play(Player player){ +// track.addSink(player); +// } + + private @Nullable VideoCapturer createCameraCapturer(CameraEnumerator enumerator) { + final String[] deviceNames = enumerator.getDeviceNames(); + + // First, try to find front facing camera + Logging.d(TAG, "Looking for front facing cameras."); + for (String deviceName : deviceNames) { + if (enumerator.isFrontFacing(deviceName)) { + Logging.d(TAG, "Creating front facing camera capturer."); + VideoCapturer videoCapturer = enumerator.createCapturer(deviceName, null); + + if (videoCapturer != null) { + return videoCapturer; + } + } + } + + // Front facing camera not found, try something else + Logging.d(TAG, "Looking for other cameras."); + for (String deviceName : deviceNames) { + if (!enumerator.isFrontFacing(deviceName)) { + Logging.d(TAG, "Creating other camera capturer."); + VideoCapturer videoCapturer = enumerator.createCapturer(deviceName, null); + + if (videoCapturer != null) { + return videoCapturer; + } + } + } + + return null; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/neurology_project_android/sampledata/Peer.java b/app/src/main/java/com/example/neurology_project_android/sampledata/Peer.java new file mode 100644 index 0000000..a74c193 --- /dev/null +++ b/app/src/main/java/com/example/neurology_project_android/sampledata/Peer.java @@ -0,0 +1,32 @@ +package com.example.neurology_project_android.sampledata; + +import com.google.gson.JsonObject; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +public class Peer { + + public String id; + public String displayName; + + private List consumers; + + Peer(String id, String displayName){ + consumers = new ArrayList<>(); + } + + void addConsumer(String id, JsonObject rtpParameters){ + String mid = rtpParameters.get("mid").getAsString(); + Consumer consumer = new Consumer(id, mid); + consumers.add(consumer); + } + + Consumer getConsumer(String consumerId){ + return consumers.stream() + .filter(c -> Objects.equals(c.id, consumerId)) + .findFirst() + .orElse(null); // 没找到返回null + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/neurology_project_android/sampledata/Player.java b/app/src/main/java/com/example/neurology_project_android/sampledata/Player.java new file mode 100644 index 0000000..5051976 --- /dev/null +++ b/app/src/main/java/com/example/neurology_project_android/sampledata/Player.java @@ -0,0 +1,49 @@ +package com.example.neurology_project_android.sampledata; + +import android.content.Context; +import android.util.AttributeSet; +import android.widget.FrameLayout; + +import org.webrtc.EglBase; +import org.webrtc.MediaStreamTrack; +import org.webrtc.SurfaceViewRenderer; +import org.webrtc.VideoTrack; + +public class Player extends FrameLayout { + + private SurfaceViewRenderer renderer; + + public Player(Context context) { + super(context); + init(context); + } + + public Player(Context context, AttributeSet attrs) { + super(context, attrs); + init(context); + } + + private void init(Context context) { + renderer = new SurfaceViewRenderer(context); + addView(renderer, new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)); + } + + public void initEgl(EglBase.Context context){ + renderer.init(context, null); + } + + public void setLayoutParams(LayoutParams params){ + renderer.setLayoutParams(params); + } + + public void play(LocalVideoSource source){ + source.track.addSink(renderer); + } + + void play(VideoTrack track){ + track.addSink(renderer); + } + + + +} \ No newline at end of file diff --git a/app/src/main/java/com/example/neurology_project_android/sampledata/RecvTransport.java b/app/src/main/java/com/example/neurology_project_android/sampledata/RecvTransport.java new file mode 100644 index 0000000..af23fef --- /dev/null +++ b/app/src/main/java/com/example/neurology_project_android/sampledata/RecvTransport.java @@ -0,0 +1,184 @@ +package com.example.neurology_project_android.sampledata; + +import android.util.Log; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; + +import org.json.JSONException; +import org.json.JSONObject; +import org.webrtc.MediaConstraints; +import org.webrtc.MediaStreamTrack; +import org.webrtc.RtpReceiver; +import org.webrtc.RtpTransceiver; +import org.webrtc.SessionDescription; + +import java.util.concurrent.Callable; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; +import java.util.function.Consumer; + +import one.dugon.mediasoup_android_sdk.sdp.Parser; + +public class RecvTransport{ + private static final String TAG = "RecvTransport"; + + public Consumer onConnect; + public Consumer onTrack = null; + + Transport transport; + public RecvTransport(String id, JsonObject iceParameters, JsonArray iceCandidates, JsonObject dtlsParameters) { + transport = new Transport(id, iceParameters, iceCandidates, dtlsParameters); + transport.onConnect = (JSONObject dtls)->{ + Log.d(TAG, "onConnect:"); + onConnect.accept(dtls); + }; + +// transport.onTrack = (String trackId)->{ +// onTrack.accept(trackId); +// }; + transport.onTrack = (MediaStreamTrack track)->{ + onTrack.accept(track); + }; + + } + + public com.example.neurology_project_android.sampledata.Consumer receive(String id, String kind, JsonObject rtpParameters){ + + Callable task = () -> receiveInternal(id, kind, rtpParameters); + + Future future = transport.executor.submit(task); + + try { + return future.get(); + } catch (Exception e) { +// e.printStackTrace(); + } + return null; + } + + + private com.example.neurology_project_android.sampledata.Consumer receiveInternal(String id, String kind, JsonObject rtpParameters){ + // TODO: 2025/3/2 maybe get mid from mapMidTransceiver + // https://github.com/versatica/libmediasoupclient/blob/v3/src/Handler.cpp#L652C35-L652C52 + String localId = rtpParameters.get("mid").getAsString(); + String cname = rtpParameters.getAsJsonObject("rtcp").get("cname").getAsString(); + + transport.remoteSdp.receive(localId, kind, rtpParameters,cname,id); +// + String offer = transport.remoteSdp.getSdp(); + + Log.i(TAG, offer); +// + CompletableFuture futureSetRemote = new CompletableFuture<>(); +// + transport.pc.setRemoteDescription(new Device.SDPObserverForRtpCaps(){ + @Override + public void onSetSuccess() { + futureSetRemote.complete(null); + } + + @Override + public void onSetFailure(String error) { + futureSetRemote.completeExceptionally(new Exception(error)); + } + }, new SessionDescription(SessionDescription.Type.OFFER,offer)); + + try { + futureSetRemote.get(); + Log.i(TAG,"setRemoteDescription ok"); + + } catch (ExecutionException e) { + throw new RuntimeException(e); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + + + MediaConstraints sdpMediaConstraints = new MediaConstraints(); + + CompletableFuture futureDesc = new CompletableFuture<>(); + + transport.pc.createAnswer(new Device.SDPObserverForRtpCaps() { + @Override + public void onCreateSuccess(SessionDescription desc) { + Log.i(TAG,"createAnswer ok"); + + futureDesc.complete(desc); + } + + @Override + public void onCreateFailure(String error) { + futureDesc.completeExceptionally(new Exception(error)); + } + }, sdpMediaConstraints); + + try { + SessionDescription answer = futureDesc.get(); + Log.d(TAG, answer.description); + Log.d(TAG, localId); + + + JsonObject localSdpObj = Parser.parse(answer.description); + + // TODO: 2025/3/3 + // May need to modify codec parameters in the answer based on codec + // parameters in the offer. + + // + // var media = localSdpObj.getAsJsonArray("media"); + // JsonObject m_select; + // for (JsonElement m : media) { + // JsonObject m_n = m.getAsJsonObject(); + // if (Objects.equals(m_n.get("mid").getAsString(), localId)){ + // m_select = m_n; + // Log.i(TAG, "selected:"+localId); + // } + // } + // https://github.com/versatica/libmediasoupclient/blob/v3/src/Handler.cpp#L679 + //Sdp::Utils::applyCodecParameters(*rtpParameters, answerMediaObject); + + if(!transport.ready){ + transport.SetupTransport("", localSdpObj); + } + + Log.d(TAG, "ready!!"); + + CompletableFuture futureDesc2 = new CompletableFuture<>(); + + transport.pc.setLocalDescription(new Device.SDPObserverForRtpCaps() { + @Override + public void onSetSuccess() { + Log.i(TAG,"setLocalDescription ok"); + futureDesc2.complete(null); + } + + @Override + public void onSetFailure(String error) { + Log.i(TAG,"setLocalDescription "+error); + futureDesc2.completeExceptionally(new Exception(error)); + } + }, answer); + + futureDesc2.get(); + + //https://bugs.chromium.org/p/webrtc/issues/detail?id=10788&q=getTransceivers()&colspec=ID%20Pri%20Stars%20M%20Component%20Status%20Owner%20Summary%20Modified +// var transceivers = pc.getTransceivers(); +// RtpTransceiver rtpTransceiver = null; +// for (var t : transceivers){ +// if(localId.equals(t.getMid())){ +// rtpTransceiver = t; +// } +// } +// +// return rtpTransceiver; + com.example.neurology_project_android.sampledata.Consumer consumer = new com.example.neurology_project_android.sampledata.Consumer(id,localId); + return consumer; + } catch (ExecutionException | JSONException e) { + throw new RuntimeException(e); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/neurology_project_android/sampledata/SendTransport.java b/app/src/main/java/com/example/neurology_project_android/sampledata/SendTransport.java new file mode 100644 index 0000000..2981dad --- /dev/null +++ b/app/src/main/java/com/example/neurology_project_android/sampledata/SendTransport.java @@ -0,0 +1,177 @@ +package com.example.neurology_project_android.sampledata; + +import android.util.Log; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; + +import org.json.JSONException; +import org.json.JSONObject; +import org.webrtc.MediaConstraints; +import org.webrtc.MediaStreamTrack; +import org.webrtc.PeerConnection; +import org.webrtc.RtpParameters; +import org.webrtc.RtpTransceiver; +import org.webrtc.SessionDescription; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.function.Consumer; +import java.util.function.Function; + +import one.dugon.mediasoup_android_sdk.sdp.Parser; +import one.dugon.mediasoup_android_sdk.sdp.RemoteSdp; +import one.dugon.mediasoup_android_sdk.sdp.Utils; + + +public class SendTransport { + + private static final String TAG = "SendTransport"; + + public Function onProduce; + + public JsonObject sendingRtpParametersByKind; + public JsonObject sendingRemoteRtpParametersByKind; + + public Consumer onConnect; + + public Transport transport; + public JsonObject produceData; + + public SendTransport(String id, + JsonObject iceParameters, + JsonArray iceCandidates, + JsonObject dtlsParameters, + JsonObject sendingRtpParametersByKind, + JsonObject sendingRemoteRtpParametersByKind) { + transport = new Transport(id,iceParameters,iceCandidates,dtlsParameters); + transport.onConnect = (JSONObject dtls)->{ + Log.d(TAG, "onConnect:"); + //Log.d(TAG, "dtls is" + dtls.toString()); + //onConnect.accept(dtls); + }; + + this.sendingRtpParametersByKind = sendingRtpParametersByKind.deepCopy(); + this.sendingRemoteRtpParametersByKind = sendingRemoteRtpParametersByKind.deepCopy(); + } + + public void start(PeerConnection peerConnection) { + transport.pc = peerConnection; + } + + + + // + public void send(LocalSource source) throws JSONException { + + MediaStreamTrack track = source.getTrack(); + List encodings = new ArrayList<>(); + + JsonObject sendingRtpParameters = sendingRtpParametersByKind.getAsJsonObject(track.kind()).deepCopy(); + JsonObject sendingRemoteRtpParameters = sendingRemoteRtpParametersByKind.getAsJsonObject(track.kind()).deepCopy(); +// reduceCodecs + RemoteSdp.MediaSectionIdx mediaSectionIdx = transport.remoteSdp.getNextMediaSectionIdx(); + RtpTransceiver transceiver = transport.pc.addTransceiver(track); + + MediaConstraints sdpMediaConstraints = new MediaConstraints(); + + CompletableFuture futureDesc = new CompletableFuture<>(); + + transport.pc.createOffer(new Device.SDPObserverForRtpCaps() { + @Override + public void onCreateSuccess(SessionDescription desc) { + futureDesc.complete(desc); + } + + @Override + public void onCreateFailure(String error) { + futureDesc.completeExceptionally(new Exception(error)); + } + }, sdpMediaConstraints); + + try { + SessionDescription sdp = futureDesc.get(); + Log.d(TAG, sdp.description); + + CompletableFuture futureDesc2 = new CompletableFuture<>(); + + transport.pc.setLocalDescription(new Device.SDPObserverForRtpCaps() { + @Override + public void onSetSuccess() { + futureDesc2.complete(null); + } + + @Override + public void onSetFailure(String error) { + futureDesc2.completeExceptionally(new Exception(error)); + } + }, sdp); + + futureDesc2.get(); + String localId = transceiver.getMid(); + Log.d(TAG, "localId:" + localId); + + sendingRtpParameters.addProperty("mid", localId); + } catch (ExecutionException e) { + throw new RuntimeException(e); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + SessionDescription localSdp = transport.pc.getLocalDescription(); + JsonObject localSdpObj = Parser.parse(localSdp.description); + + if(!transport.ready){ + transport.SetupTransport("", localSdpObj); + } + +// var localSdpStr = Writer.write(localSdpObj); +// Log.d(TAG,localSdpStr); +// Log.d(TAG,localSdpObj.get("media").getAsJsonArray().get(0).getAsJsonObject().get("ssrcs").toString()); + + JsonObject offerMediaObject = localSdpObj.getAsJsonArray("media").get(mediaSectionIdx.idx).getAsJsonObject(); + + sendingRtpParameters.getAsJsonObject("rtcp").addProperty("cname", Utils.getCname(offerMediaObject)); + + sendingRtpParameters.add("encodings", Utils.getRtpEncodings(offerMediaObject)); + +// Log.d(TAG,"codec:"+sendingRemoteRtpParameters.getAsJsonArray("codecs").toString()); + // TODO: 2024/10/11 fix mid + transport.remoteSdp.send(offerMediaObject, "", sendingRtpParameters, sendingRemoteRtpParameters, null); + + String remoteSdpStr = transport.remoteSdp.getSdp(); + //Log.d(TAG, remoteSdpStr); + + CompletableFuture futureSetRemote = new CompletableFuture<>(); + + transport.pc.setRemoteDescription(new Device.SDPObserverForRtpCaps(){ + @Override + public void onSetSuccess() { + futureSetRemote.complete(null); + } + + @Override + public void onSetFailure(String error) { + futureSetRemote.completeExceptionally(new Exception(error)); + } + },new SessionDescription(SessionDescription.Type.ANSWER,remoteSdpStr)); + + try { + futureSetRemote.get(); + + } catch (ExecutionException e) { + throw new RuntimeException(e); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + + this.produceData = new JsonObject(); + produceData.addProperty("kind",track.kind()); + produceData.add("rtpParameters",sendingRtpParameters); + //String producerId = onProduce.apply(produceData); + //Log.d(TAG,"pid:"+producerId); + } + + +} \ No newline at end of file diff --git a/app/src/main/java/com/example/neurology_project_android/sampledata/Transport.java b/app/src/main/java/com/example/neurology_project_android/sampledata/Transport.java new file mode 100644 index 0000000..89b3a6c --- /dev/null +++ b/app/src/main/java/com/example/neurology_project_android/sampledata/Transport.java @@ -0,0 +1,154 @@ +package com.example.neurology_project_android.sampledata; + +import android.util.Log; + +import androidx.annotation.Nullable; + +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; + +import org.json.JSONException; +import org.json.JSONObject; +import org.webrtc.DataChannel; +import org.webrtc.IceCandidate; +import org.webrtc.MediaStream; +import org.webrtc.MediaStreamTrack; +import org.webrtc.PeerConnection; +import org.webrtc.RtpReceiver; + +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.function.Consumer; + +import one.dugon.mediasoup_android_sdk.sdp.RemoteSdp; +import one.dugon.mediasoup_android_sdk.sdp.Utils; + + +public class Transport implements PeerConnection.Observer{ + private static final String TAG = "Transport"; + + public String id ; + public RemoteSdp remoteSdp; + + public Consumer onConnect; + public Consumer onTrack = null; + + @Nullable + public PeerConnection pc; + + public boolean ready = false; + public ExecutorService executor = Executors.newSingleThreadExecutor(); + + + public Transport(String id, + JsonObject iceParameters, + JsonArray iceCandidates, + JsonObject dtlsParameters){ + this.id = id; + this.remoteSdp = new RemoteSdp(iceParameters, iceCandidates, dtlsParameters, null); + } + + public void start(PeerConnection peerConnection) { + pc = peerConnection; + } + + public void SetupTransport(String localDtlsRole, JsonObject localSdpObject) throws JSONException { + + + // Get our local DTLS parameters. + Gson gson = new Gson(); + + + // 2. Serialize the Gson JsonObject into a raw JSON string + JsonObject dtlsParameters = Utils.extractDtlsParameters(localSdpObject); + + + dtlsParameters.addProperty("role","client"); + String toConvert = gson.toJson(dtlsParameters); + Log.e("TRANSPORT:", "TO CONVERT STRING " + toConvert); + onConnect.accept(new JSONObject(toConvert)); + // Set our DTLS role. +// dtlsParameters["role"] = localDtlsRole; + + // Update the remote DTLS role in the SDP. +// var remoteDtlsRole = localDtlsRole.equals("client") ? "server" : "client"; +// this->remoteSdp->UpdateDtlsRole(remoteDtlsRole); + remoteSdp.updateDtlsRole("server"); + + // May throw. +// this->privateListener->OnConnect(dtlsParameters); + ready = true; + } + + + @Override + public void onSignalingChange(PeerConnection.SignalingState signalingState) { + + } + + @Override + public void onIceConnectionChange(PeerConnection.IceConnectionState iceConnectionState) { + Log.d(TAG,"iceState:"+iceConnectionState.name()); + } + + @Override + public void onIceConnectionReceivingChange(boolean b) { + + } + + @Override + public void onIceGatheringChange(PeerConnection.IceGatheringState iceGatheringState) { + + } + + @Override + public void onIceCandidate(IceCandidate iceCandidate) { + + } + + @Override + public void onIceCandidatesRemoved(IceCandidate[] iceCandidates) { + + } + + @Override + public void onAddStream(MediaStream mediaStream) { + + } + + @Override + public void onRemoveStream(MediaStream mediaStream) { + + } + + @Override + public void onDataChannel(DataChannel dataChannel) { + + } + + @Override + public void onRenegotiationNeeded() { + + } + + @Override + public void onAddTrack(RtpReceiver receiver, MediaStream[] mediaStreams) { +// if(onTrack != null){ +// Log.d(TAG,"onAddTrack " + receiver.track().kind()); +// +// if(Objects.equals(receiver.track().kind(), "video")) { +// onTrack.accept(receiver.track().id()); +// tracks.put(receiver.track().id(),receiver.track()); +// } +// } + if(onTrack != null){ + onTrack.accept(receiver.track()); + } + } + + @Override + public void onRemoveTrack(RtpReceiver receiver) { + Log.d(TAG,"onRemoveTrack"); + } +} \ No newline at end of file diff --git a/gradle.properties b/gradle.properties index 20e2a01..2f6baf7 100644 --- a/gradle.properties +++ b/gradle.properties @@ -20,4 +20,5 @@ kotlin.code.style=official # Enables namespacing of each library's R class so that its R class includes only the # resources declared in the library itself and none from the library's dependencies, # thereby reducing the size of the R class for that library -android.nonTransitiveRClass=true \ No newline at end of file +android.nonTransitiveRClass=true +android.enableJetifier=true \ No newline at end of file From c49e9de84d1972b048d1b929d2940eb6daedc8c3 Mon Sep 17 00:00:00 2001 From: David Date: Thu, 16 Oct 2025 23:13:14 -0400 Subject: [PATCH 02/14] changes to get it working with new server --- app/build.gradle.kts | 6 ++--- .../sampledata/Device.java | 23 +++++++++++-------- .../sampledata/Engine.java | 4 ++-- .../sampledata/LocalSource.java | 1 - .../sampledata/LocalVideoSource.java | 2 +- .../sampledata/SendTransport.java | 7 +++--- gradle/libs.versions.toml | 2 +- 7 files changed, 24 insertions(+), 21 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 866d65f..2801b25 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -43,11 +43,11 @@ android { "proguard-rules.pro" ) - buildConfigField("String", "BASE_API_URL", "\"https://videochat-signaling-app.ue.r.appspot.com\"") + buildConfigField("String", "BASE_API_URL", "\"https://meechie.techkit.xyz\"") buildConfigField("String", "BASE_WS_API_URL", "\"wss://videochat-signaling-app.ue.r.appspot.com\"") - buildConfigField("int", "PORT", "443") + buildConfigField("int", "PORT", "3016") buildConfigField("boolean", "SECURE", "true") // yes use HTTPS - buildConfigField("String", "API_POST_URL","\"https://videochat-signaling-app.ue.r.appspot.com/key=peerjs/post\"") + buildConfigField("String", "API_POST_URL","\"https://meechie.techkit.xyz:3016/key=peerjs/post\"") buildConfigField("String", "API_GET_PEERS_URL","\"https://videochat-signaling-app.ue.r.appspot.com/key=peerjs/peers\"") signingConfig = signingConfigs.getByName("debug") } diff --git a/app/src/main/java/com/example/neurology_project_android/sampledata/Device.java b/app/src/main/java/com/example/neurology_project_android/sampledata/Device.java index 2b041e2..725206a 100644 --- a/app/src/main/java/com/example/neurology_project_android/sampledata/Device.java +++ b/app/src/main/java/com/example/neurology_project_android/sampledata/Device.java @@ -10,7 +10,11 @@ import org.webrtc.AudioTrack; import org.webrtc.CandidatePairChangeEvent; import org.webrtc.DataChannel; +import org.webrtc.DefaultVideoDecoderFactory; +import org.webrtc.DefaultVideoEncoderFactory; import org.webrtc.EglBase; +import org.webrtc.HardwareVideoDecoderFactory; +import org.webrtc.HardwareVideoEncoderFactory; import org.webrtc.IceCandidate; import org.webrtc.IceCandidateErrorEvent; import org.webrtc.MediaConstraints; @@ -68,7 +72,7 @@ public static void initialize(Context context) { PeerConnectionFactory.initialize( PeerConnectionFactory.InitializationOptions.builder(appContext) // .setFieldTrials(fieldTrials) - .setEnableInternalTracer(true) + .setEnableInternalTracer(false) .createInitializationOptions()); @@ -77,7 +81,7 @@ public static void initialize(Context context) { // Create peer connection factory. PeerConnectionFactory.Options options = new PeerConnectionFactory.Options(); - + options.disableNetworkMonitor = false; // final boolean enableH264HighProfile = // VIDEO_CODEC_H264_HIGH.equals(peerConnectionParameters.videoCodec); final VideoEncoderFactory encoderFactory; @@ -88,8 +92,8 @@ public static void initialize(Context context) { // rootEglBase.getEglBaseContext(), true /* enableIntelVp8Encoder */, enableH264HighProfile); // decoderFactory = new DefaultVideoDecoderFactory(rootEglBase.getEglBaseContext()); - encoderFactory = new SoftwareVideoEncoderFactory(); - decoderFactory = new SoftwareVideoDecoderFactory(); + encoderFactory = new DefaultVideoEncoderFactory(rootEglBase.getEglBaseContext(),true, false); + decoderFactory = new DefaultVideoDecoderFactory(rootEglBase.getEglBaseContext()); // Disable encryption for loopback calls. // if (peerConnectionParameters.loopback) { @@ -98,7 +102,7 @@ public static void initialize(Context context) { factory = PeerConnectionFactory.builder() .setOptions(options) - .setAudioDeviceModule(adm) + //.setAudioDeviceModule(adm) .setVideoEncoderFactory(encoderFactory) .setVideoDecoderFactory(decoderFactory) .createPeerConnectionFactory(); @@ -125,12 +129,12 @@ public static JsonObject getRtpCapabilities() { new PeerConnection.RTCConfiguration(iceServers); // TCP candidates are only useful when connecting to a server that supports // ICE-TCP. - rtcConfig.tcpCandidatePolicy = PeerConnection.TcpCandidatePolicy.DISABLED; + rtcConfig.tcpCandidatePolicy = PeerConnection.TcpCandidatePolicy.ENABLED; rtcConfig.bundlePolicy = PeerConnection.BundlePolicy.MAXBUNDLE; - rtcConfig.rtcpMuxPolicy = PeerConnection.RtcpMuxPolicy.REQUIRE; - rtcConfig.continualGatheringPolicy = PeerConnection.ContinualGatheringPolicy.GATHER_CONTINUALLY; + //rtcConfig.rtcpMuxPolicy = PeerConnection.RtcpMuxPolicy.REQUIRE; + //rtcConfig.continualGatheringPolicy = PeerConnection.ContinualGatheringPolicy.GATHER_CONTINUALLY; // Use ECDSA encryption. - rtcConfig.keyType = PeerConnection.KeyType.ECDSA; + //rtcConfig.keyType = PeerConnection.KeyType.ECDSA; rtcConfig.sdpSemantics = PeerConnection.SdpSemantics.UNIFIED_PLAN; assert factory != null; @@ -466,7 +470,6 @@ public static RecvTransport createRecvTransport( PeerConnection.RTCConfiguration rtcConfig = new PeerConnection.RTCConfiguration(iceServers); - rtcConfig.bundlePolicy = PeerConnection.BundlePolicy.MAXBUNDLE; PeerConnection peerConnection = factory.createPeerConnection(rtcConfig, t.transport); diff --git a/app/src/main/java/com/example/neurology_project_android/sampledata/Engine.java b/app/src/main/java/com/example/neurology_project_android/sampledata/Engine.java index 9d3c97c..f749a53 100644 --- a/app/src/main/java/com/example/neurology_project_android/sampledata/Engine.java +++ b/app/src/main/java/com/example/neurology_project_android/sampledata/Engine.java @@ -68,7 +68,7 @@ public interface Listener { public Engine(Context context){ - protoo = new ProtooSocket(); + //protoo = new ProtooSocket(); consumerHashMap = new HashMap<>(); tracks = new HashMap<>(); Device.initialize(context); @@ -89,7 +89,7 @@ public void onConnect() { getRtpCaps(); createWebRTCTransport(false); createWebRTCTransport(true); - join(); + //join(); }); } diff --git a/app/src/main/java/com/example/neurology_project_android/sampledata/LocalSource.java b/app/src/main/java/com/example/neurology_project_android/sampledata/LocalSource.java index accb5d0..eae742b 100644 --- a/app/src/main/java/com/example/neurology_project_android/sampledata/LocalSource.java +++ b/app/src/main/java/com/example/neurology_project_android/sampledata/LocalSource.java @@ -2,7 +2,6 @@ import org.webrtc.MediaStreamTrack; - // FIX 1: Make the class public so your RoomClient can inherit/reference it. public abstract class LocalSource { diff --git a/app/src/main/java/com/example/neurology_project_android/sampledata/LocalVideoSource.java b/app/src/main/java/com/example/neurology_project_android/sampledata/LocalVideoSource.java index 5a561d2..08e5b64 100644 --- a/app/src/main/java/com/example/neurology_project_android/sampledata/LocalVideoSource.java +++ b/app/src/main/java/com/example/neurology_project_android/sampledata/LocalVideoSource.java @@ -34,7 +34,7 @@ public LocalVideoSource(Context context, EglBase rootEglBase, VideoSource videoS surfaceTextureHelper = SurfaceTextureHelper.create("CaptureThread", rootEglBase.getEglBaseContext()); capturer.initialize(surfaceTextureHelper, appContext, source.getCapturerObserver()); - capturer.startCapture(720, 1280, 30); + capturer.startCapture(1080, 1920, 30); } @Override diff --git a/app/src/main/java/com/example/neurology_project_android/sampledata/SendTransport.java b/app/src/main/java/com/example/neurology_project_android/sampledata/SendTransport.java index 2981dad..6679d10 100644 --- a/app/src/main/java/com/example/neurology_project_android/sampledata/SendTransport.java +++ b/app/src/main/java/com/example/neurology_project_android/sampledata/SendTransport.java @@ -136,12 +136,13 @@ public void onSetFailure(String error) { sendingRtpParameters.add("encodings", Utils.getRtpEncodings(offerMediaObject)); -// Log.d(TAG,"codec:"+sendingRemoteRtpParameters.getAsJsonArray("codecs").toString()); + Log.d(TAG,"codec:"+sendingRemoteRtpParameters.getAsJsonArray("codecs").toString()); // TODO: 2024/10/11 fix mid - transport.remoteSdp.send(offerMediaObject, "", sendingRtpParameters, sendingRemoteRtpParameters, null); + transport.remoteSdp.send(offerMediaObject, "", sendingRtpParameters, sendingRemoteRtpParameters, null); + Log.d(TAG, sendingRemoteRtpParameters.toString()); String remoteSdpStr = transport.remoteSdp.getSdp(); - //Log.d(TAG, remoteSdpStr); + CompletableFuture futureSetRemote = new CompletableFuture<>(); diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f2c6bf6..3e8290a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -12,7 +12,7 @@ navigationCompose = "2.8.5" firebaseDatabaseKtx = "21.0.0" constraintlayout = "2.2.0" media3CommonKtx = "1.5.1" -cameraCore = "1.4.2" +cameraCore = "1.5.1" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } From d1541aca34f838c1a58cea646f5c9d77c80bea99 Mon Sep 17 00:00:00 2001 From: deHank Date: Mon, 20 Oct 2025 15:32:37 -0400 Subject: [PATCH 03/14] ** Change to allow viewers to see the screen of the VR client ** --- .../neurology_project_android/MainActivity.kt | 131 ++++++++++++------ .../sampledata/LocalVideoSource.java | 43 +++--- 2 files changed, 112 insertions(+), 62 deletions(-) diff --git a/app/src/main/java/com/example/neurology_project_android/MainActivity.kt b/app/src/main/java/com/example/neurology_project_android/MainActivity.kt index 7f99806..ff2de00 100644 --- a/app/src/main/java/com/example/neurology_project_android/MainActivity.kt +++ b/app/src/main/java/com/example/neurology_project_android/MainActivity.kt @@ -4,6 +4,7 @@ package com.example.neurology_project_android import androidx.compose.ui.platform.LocalContext import android.Manifest +import android.R import android.annotation.SuppressLint import android.app.PendingIntent import android.content.Intent @@ -15,22 +16,28 @@ import androidx.activity.enableEdgeToEdge import androidx.annotation.OptIn import androidx.annotation.RequiresApi import androidx.camera.core.imagecapture.CameraRequest +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text @@ -45,23 +52,17 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.media3.common.util.Log import androidx.media3.common.util.UnstableApi import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController -import com.example.neurology_project_android.BuildConfig.BASE_WS_API_URL -import com.example.neurology_project_android.BuildConfig.PORT import com.example.neurology_project_android.ui.theme.NeurologyProjectAndroidTheme -import okhttp3.Call -import okhttp3.Callback import okhttp3.OkHttpClient import okhttp3.Request -import okhttp3.Response -import okio.IOException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -77,6 +78,9 @@ class MainActivity : ComponentActivity() { private var cameraInitialized by mutableStateOf(false) private lateinit var signalingClient: SignalingClient + + + @SuppressLint("UnusedMaterial3ScaffoldPaddingParameter") @RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM) @OptIn(UnstableApi::class) override fun onCreate(savedInstanceState: Bundle?) { @@ -144,21 +148,13 @@ class MainActivity : ComponentActivity() { Scaffold( modifier = Modifier.fillMaxSize(), containerColor = Color.Transparent, - content = { innerPadding -> - HomeScreen( - modifier = Modifier.padding(innerPadding), - peerId = userId, - peers = peersState.value - ) -// Greeting( -// name = "Android", -// modifier = Modifier.padding(innerPadding), -// signalingClient = signalingClient, -// cameraInitialized = cameraInitialized, -// cameraRequest = { cameraRequest }, -// isInCall = isInCall -// ) + content = { + innerPadding -> + + myApp(peersState.value, innerPadding) } + + ) } } @@ -167,7 +163,21 @@ class MainActivity : ComponentActivity() { @RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM) @Composable - fun OnlineNowSection(peers: List) { + fun OnlineNowSection(peers: List, onNavigateToOnlineScreen: () -> Unit) { + + +// LaunchedEffect(isInCall) { +// if (isInCall) { +// navController.navigate("callScreen") +// } else { +// navController.navigate("home") // Navigate back when call ends +// } +// } + + + + + Text( text = "Online Now:", fontWeight = FontWeight.Bold, @@ -179,7 +189,7 @@ class MainActivity : ComponentActivity() { Text(text = "No peers online", modifier = Modifier.padding(16.dp)) } else { peers.forEach { userId -> - OnlineUserCard(userId) + OnlineUserCard(userId, onNavigateToOnlineScreen) } } } @@ -187,7 +197,8 @@ class MainActivity : ComponentActivity() { @RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM) @Composable - fun OnlineUserCard(userId: String) { + fun OnlineUserCard(userId: String, onNavigateToOnlineScreen: () -> Unit) { + var navController = rememberNavController() Card( modifier = Modifier .fillMaxWidth() @@ -209,7 +220,11 @@ class MainActivity : ComponentActivity() { .padding(end = 16.dp) ) Button( - onClick = { signalingClient.joinRoom(userId) }, //signalingClient.startCall(userId) + onClick = { + + signalingClient.joinRoom(userId) + onNavigateToOnlineScreen() + }, //signalingClient.startCall(userId) modifier = Modifier.wrapContentWidth() ) { Text(text = "Call") @@ -220,8 +235,37 @@ class MainActivity : ComponentActivity() { @RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM) @Composable - fun HomeScreen(modifier: Modifier = Modifier, peerId: String, peers: List) { + fun myApp(peers: List, innerPadding: PaddingValues) { + + var navController = rememberNavController() + + NavHost(navController, startDestination = "home") { + composable("home") { + HomeScreen( + onNavigateToProfile = { navController.navigate("callScreen") }, + modifier = Modifier.padding(innerPadding), + peerId = "VR CLIENT", + peers = peers + ) + + // A simple loading/home screen + //Greeting() + } + composable("callScreen") { + CallScreen() + } + } + + } + + + + @RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM) + @Composable + fun HomeScreen(modifier: Modifier = Modifier, peerId: String, peers: List, onNavigateToProfile: () -> Unit) { val context = LocalContext.current + + val sessionManager = remember { SessionManager(context) } // Refresh UI every 3 seconds LaunchedEffect(peers) { @@ -253,7 +297,7 @@ class MainActivity : ComponentActivity() { Spacer(modifier = Modifier.height(8.dp)) - PeerIdSection(peerId) // Displays the correct Peer ID + PeerIdSection("VR CLIENT") // Displays the correct Peer ID Column( modifier = Modifier @@ -262,12 +306,14 @@ class MainActivity : ComponentActivity() { .verticalScroll(rememberScrollState()), horizontalAlignment = Alignment.CenterHorizontally ) { - OnlineNowSection(peers) // No need for additional state + OnlineNowSection(peers,onNavigateToProfile) // No need for additional state } NIHFormsButton() } } + + } suspend fun fetchUserId(): String { @@ -288,6 +334,9 @@ suspend fun fetchUserId(): String { } } + + + @RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM) @Composable fun Greeting( @@ -298,26 +347,18 @@ fun Greeting( @SuppressLint("RestrictedApi") cameraRequest: () -> CameraRequest, isInCall: Boolean ) { - val navController = rememberNavController() - LaunchedEffect(isInCall) { - if (isInCall) { - navController.navigate("callScreen") - } else { - navController.navigate("home") // Navigate back when call ends - } - } + +// LaunchedEffect(isInCall) { +// if (isInCall) { +// navController.navigate("callScreen") +// } else { +// navController.navigate("home") // Navigate back when call ends +// } +// } + - NavHost(navController, startDestination = "home") { - composable("home") { - // A simple loading/home screen - //Greeting() - } - composable("callScreen") { - CallScreen() - } - } } diff --git a/app/src/main/java/com/example/neurology_project_android/sampledata/LocalVideoSource.java b/app/src/main/java/com/example/neurology_project_android/sampledata/LocalVideoSource.java index 08e5b64..b59b498 100644 --- a/app/src/main/java/com/example/neurology_project_android/sampledata/LocalVideoSource.java +++ b/app/src/main/java/com/example/neurology_project_android/sampledata/LocalVideoSource.java @@ -1,9 +1,12 @@ package com.example.neurology_project_android.sampledata; import android.content.Context; +import android.hardware.camera2.CameraAccessException; +import android.hardware.camera2.CameraManager; import androidx.annotation.Nullable; +import org.webrtc.Camera2Capturer; import org.webrtc.Camera2Enumerator; import org.webrtc.CameraEnumerator; import org.webrtc.EglBase; @@ -14,27 +17,33 @@ import org.webrtc.VideoSource; import org.webrtc.VideoTrack; -public class LocalVideoSource extends LocalSource{ +public class LocalVideoSource extends LocalSource { private static final String TAG = "LocalVideoSource"; - - private VideoCapturer capturer; - private VideoSource source; private final Context appContext; - private SurfaceTextureHelper surfaceTextureHelper; - public VideoTrack track; + private final VideoCapturer capturer; + private final VideoSource source; + private final SurfaceTextureHelper surfaceTextureHelper; - public LocalVideoSource(Context context, EglBase rootEglBase, VideoSource videoSource, VideoTrack videoTrack){ + public LocalVideoSource(Context context, EglBase rootEglBase, VideoSource videoSource, VideoTrack videoTrack) throws CameraAccessException { appContext = context; source = videoSource; track = videoTrack; - // - capturer = createCameraCapturer(new Camera2Enumerator(appContext)); + CameraManager cameraManager = (CameraManager) context.getSystemService(Context.CAMERA_SERVICE); + //String lastCameraId = cameraManager.getCameraIdList()[cameraManager.getCameraIdList().length-1]; + String lastCameraId = cameraManager.getCameraIdList()[cameraManager.getCameraIdList().length - 1]; + Camera2Capturer cameraCapturer = new Camera2Capturer(context, lastCameraId, null); + + CameraEnumerator enumerator = new Camera2Enumerator(appContext); + capturer = createCameraCapturer(enumerator); +// capturer = createCameraCapturer(new Camera2Enumerator(appContext)); surfaceTextureHelper = SurfaceTextureHelper.create("CaptureThread", rootEglBase.getEglBaseContext()); - capturer.initialize(surfaceTextureHelper, appContext, source.getCapturerObserver()); - capturer.startCapture(1080, 1920, 30); +// capturer.initialize(surfaceTextureHelper, appContext, source.getCapturerObserver()); +// capturer.startCapture(1080, 1920, 30); + cameraCapturer.initialize(surfaceTextureHelper, appContext, source.getCapturerObserver()); + cameraCapturer.startCapture(1920, 1080, 30); } @Override @@ -58,14 +67,14 @@ public String getKind() { // First, try to find front facing camera Logging.d(TAG, "Looking for front facing cameras."); for (String deviceName : deviceNames) { - if (enumerator.isFrontFacing(deviceName)) { - Logging.d(TAG, "Creating front facing camera capturer."); - VideoCapturer videoCapturer = enumerator.createCapturer(deviceName, null); +// if (enumerator.isFrontFacing(deviceName)) { + Logging.d(TAG, "Creating camera capturer."); + VideoCapturer videoCapturer = enumerator.createCapturer(deviceName, null); - if (videoCapturer != null) { - return videoCapturer; - } + if (videoCapturer != null) { + return videoCapturer; } +// } } // Front facing camera not found, try something else From 36b3000b872a074c9ee8c9a38b9c21e01034da2d Mon Sep 17 00:00:00 2001 From: David Date: Wed, 15 Oct 2025 15:40:30 -0400 Subject: [PATCH 04/14] ** Change to allow viewers to see the screen of the VR client ** changes to get it working with new server Got mediasoup working! --- app/build.gradle.kts | 16 +- .../LoginActivity.kt | 5 +- .../neurology_project_android/MainActivity.kt | 165 +++-- .../neurology_project_android/RoomClient.kt | 301 ++++++++ .../SignalingClient.kt | 677 +++--------------- .../VideoCameraSetup.kt | 334 ++++----- .../sampledata/Consumer.java | 35 + .../sampledata/Device.java | 479 +++++++++++++ .../sampledata/Engine.java | 314 ++++++++ .../sampledata/LocalAudioSource.java | 25 + .../sampledata/LocalSource.java | 15 + .../sampledata/LocalVideoSource.java | 95 +++ .../sampledata/Peer.java | 32 + .../sampledata/Player.java | 49 ++ .../sampledata/RecvTransport.java | 184 +++++ .../sampledata/SendTransport.java | 178 +++++ .../sampledata/Transport.java | 154 ++++ gradle.properties | 3 +- gradle/libs.versions.toml | 2 +- 19 files changed, 2254 insertions(+), 809 deletions(-) create mode 100644 app/src/main/java/com/example/neurology_project_android/RoomClient.kt create mode 100644 app/src/main/java/com/example/neurology_project_android/sampledata/Consumer.java create mode 100644 app/src/main/java/com/example/neurology_project_android/sampledata/Device.java create mode 100644 app/src/main/java/com/example/neurology_project_android/sampledata/Engine.java create mode 100644 app/src/main/java/com/example/neurology_project_android/sampledata/LocalAudioSource.java create mode 100644 app/src/main/java/com/example/neurology_project_android/sampledata/LocalSource.java create mode 100644 app/src/main/java/com/example/neurology_project_android/sampledata/LocalVideoSource.java create mode 100644 app/src/main/java/com/example/neurology_project_android/sampledata/Peer.java create mode 100644 app/src/main/java/com/example/neurology_project_android/sampledata/Player.java create mode 100644 app/src/main/java/com/example/neurology_project_android/sampledata/RecvTransport.java create mode 100644 app/src/main/java/com/example/neurology_project_android/sampledata/SendTransport.java create mode 100644 app/src/main/java/com/example/neurology_project_android/sampledata/Transport.java diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 8468bbf..2801b25 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -27,11 +27,11 @@ android { "proguard-rules.pro" ) - buildConfigField("String", "BASE_API_URL", "\"https://videochat-signaling-app.ue.r.appspot.com\"") + buildConfigField("String", "BASE_API_URL", "\"https://meechie.techkit.xyz\"") buildConfigField("String", "BASE_WS_API_URL", "\"wss://videochat-signaling-app.ue.r.appspot.com\"") - buildConfigField("int", "PORT", "443") + buildConfigField("int", "PORT", "3016") buildConfigField("boolean", "SECURE", "true") // yes use HTTPS - buildConfigField("String", "API_POST_URL","\"https://videochat-signaling-app.ue.r.appspot.com/key=peerjs/post\"") + buildConfigField("String", "API_POST_URL","\"https://meechie.techkit.xyz:3016/key=peerjs/post\"") buildConfigField("String", "API_GET_PEERS_URL","\"https://videochat-signaling-app.ue.r.appspot.com/key=peerjs/peers\"") signingConfig = signingConfigs.getByName("debug") } @@ -43,11 +43,11 @@ android { "proguard-rules.pro" ) - buildConfigField("String", "BASE_API_URL", "\"https://videochat-signaling-app.ue.r.appspot.com\"") + buildConfigField("String", "BASE_API_URL", "\"https://meechie.techkit.xyz\"") buildConfigField("String", "BASE_WS_API_URL", "\"wss://videochat-signaling-app.ue.r.appspot.com\"") - buildConfigField("int", "PORT", "443") + buildConfigField("int", "PORT", "3016") buildConfigField("boolean", "SECURE", "true") // yes use HTTPS - buildConfigField("String", "API_POST_URL","\"https://videochat-signaling-app.ue.r.appspot.com/key=peerjs/post\"") + buildConfigField("String", "API_POST_URL","\"https://meechie.techkit.xyz:3016/key=peerjs/post\"") buildConfigField("String", "API_GET_PEERS_URL","\"https://videochat-signaling-app.ue.r.appspot.com/key=peerjs/peers\"") signingConfig = signingConfigs.getByName("debug") } @@ -100,9 +100,11 @@ android { } dependencies { + implementation ("com.github.0-u-0:mediasoup-android-sdk:0.0.1") + implementation("com.github.0-u-0:dugon-webrtc-android:100.0.2") + implementation("io.socket:socket.io-client:2.1.1") implementation ("com.github.franmontiel:PersistentCookieJar:v1.0.1") implementation("com.squareup.okhttp3:okhttp:5.0.0-alpha.14") - implementation("io.github.webrtc-sdk:android:125.6422.06.1") implementation("androidx.compose.material:material-icons-extended") implementation("androidx.room:room-runtime:2.5.0") implementation(libs.androidx.camera.core) diff --git a/app/src/main/java/com/example/neurology_project_android/LoginActivity.kt b/app/src/main/java/com/example/neurology_project_android/LoginActivity.kt index 8997ad5..e6fc8d6 100644 --- a/app/src/main/java/com/example/neurology_project_android/LoginActivity.kt +++ b/app/src/main/java/com/example/neurology_project_android/LoginActivity.kt @@ -150,8 +150,11 @@ fun LoginScreen(sessionManager: SessionManager, onLoginSuccess: () -> Unit) { override fun onResponse(call: Call, response: Response) { val success = response.isSuccessful val bodyString = response.body?.string()?.trim() ?: "" - var authToken = response.headers.value(9)?.substringAfter("authorization=")?.substringBefore(";") ?: "" + var authToken = response.headers.get("Set-Cookie") + ?.substringBefore(";") ?: "" + Log.d("LOGINACTIVITY:", "RESPONSE HEADERS: " + response.headers.get("Set-Cookie")) + Log.d("LOGINACTIVITY:", "AUTH TOKEN: " + authToken) if (success) { sessionManager.saveAuthToken(authToken, username) (context as ComponentActivity).runOnUiThread { diff --git a/app/src/main/java/com/example/neurology_project_android/MainActivity.kt b/app/src/main/java/com/example/neurology_project_android/MainActivity.kt index f6f8d2c..ff2de00 100644 --- a/app/src/main/java/com/example/neurology_project_android/MainActivity.kt +++ b/app/src/main/java/com/example/neurology_project_android/MainActivity.kt @@ -4,6 +4,7 @@ package com.example.neurology_project_android import androidx.compose.ui.platform.LocalContext import android.Manifest +import android.R import android.annotation.SuppressLint import android.app.PendingIntent import android.content.Intent @@ -15,22 +16,28 @@ import androidx.activity.enableEdgeToEdge import androidx.annotation.OptIn import androidx.annotation.RequiresApi import androidx.camera.core.imagecapture.CameraRequest +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text @@ -45,26 +52,17 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.media3.common.util.Log import androidx.media3.common.util.UnstableApi import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController -import com.example.neurology_project_android.BuildConfig.BASE_WS_API_URL -import com.example.neurology_project_android.BuildConfig.PORT import com.example.neurology_project_android.ui.theme.NeurologyProjectAndroidTheme -import okhttp3.Call -import okhttp3.Callback import okhttp3.OkHttpClient import okhttp3.Request -import okhttp3.Response -import okio.IOException -import org.webrtc.CapturerObserver -import org.webrtc.VideoProcessor -import org.webrtc.VideoSource import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -73,13 +71,16 @@ class MainActivity : ComponentActivity() { @SuppressLint("RestrictedApi") private lateinit var cameraRequest: CameraRequest - private lateinit var videoProcessor: VideoProcessor - private lateinit var videoSource: VideoSource - private lateinit var capturerObserver: CapturerObserver +// private lateinit var videoProcessor: VideoProcessor +// private lateinit var videoSource: VideoSource +// private lateinit var capturerObserver: CapturerObserver private var isInCall by mutableStateOf(false) private var cameraInitialized by mutableStateOf(false) private lateinit var signalingClient: SignalingClient + + + @SuppressLint("UnusedMaterial3ScaffoldPaddingParameter") @RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM) @OptIn(UnstableApi::class) override fun onCreate(savedInstanceState: Bundle?) { @@ -114,21 +115,24 @@ class MainActivity : ComponentActivity() { val fetchedId = fetchUserId() userIdState.value = fetchedId - // Now safe to start SignalingClient + //Now safe to start SignalingClient signalingClient = SignalingClient( - "$BASE_WS_API_URL:$PORT/peerjs?id=$fetchedId&token=6789&key=peerjs", this@MainActivity, - fetchedId, - onCallRecieved = { isInCall = true }, - onCallEnded = { runOnUiThread { isInCall = false } } - ) - // Fetch peers - GetPeers { peers -> - runOnUiThread { - peersState.value = peers.filter { it != fetchedId } + { peers -> + runOnUiThread { + peersState.value = peers.filter { it != fetchedId } + } } - } + + ) + +// // Fetch peers +// GetPeers { peers -> +// runOnUiThread { +// peersState.value = peers.filter { it != fetchedId } +// } +// } } val userId = userIdState.value @@ -144,21 +148,13 @@ class MainActivity : ComponentActivity() { Scaffold( modifier = Modifier.fillMaxSize(), containerColor = Color.Transparent, - content = { innerPadding -> - HomeScreen( - modifier = Modifier.padding(innerPadding), - peerId = userId, - peers = peersState.value - ) - Greeting( - name = "Android", - modifier = Modifier.padding(innerPadding), - signalingClient = signalingClient, - cameraInitialized = cameraInitialized, - cameraRequest = { cameraRequest }, - isInCall = isInCall - ) + content = { + innerPadding -> + + myApp(peersState.value, innerPadding) } + + ) } } @@ -167,7 +163,21 @@ class MainActivity : ComponentActivity() { @RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM) @Composable - fun OnlineNowSection(peers: List) { + fun OnlineNowSection(peers: List, onNavigateToOnlineScreen: () -> Unit) { + + +// LaunchedEffect(isInCall) { +// if (isInCall) { +// navController.navigate("callScreen") +// } else { +// navController.navigate("home") // Navigate back when call ends +// } +// } + + + + + Text( text = "Online Now:", fontWeight = FontWeight.Bold, @@ -179,7 +189,7 @@ class MainActivity : ComponentActivity() { Text(text = "No peers online", modifier = Modifier.padding(16.dp)) } else { peers.forEach { userId -> - OnlineUserCard(userId) + OnlineUserCard(userId, onNavigateToOnlineScreen) } } } @@ -187,7 +197,8 @@ class MainActivity : ComponentActivity() { @RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM) @Composable - fun OnlineUserCard(userId: String) { + fun OnlineUserCard(userId: String, onNavigateToOnlineScreen: () -> Unit) { + var navController = rememberNavController() Card( modifier = Modifier .fillMaxWidth() @@ -209,7 +220,11 @@ class MainActivity : ComponentActivity() { .padding(end = 16.dp) ) Button( - onClick = { signalingClient.startCall(userId) }, + onClick = { + + signalingClient.joinRoom(userId) + onNavigateToOnlineScreen() + }, //signalingClient.startCall(userId) modifier = Modifier.wrapContentWidth() ) { Text(text = "Call") @@ -220,8 +235,37 @@ class MainActivity : ComponentActivity() { @RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM) @Composable - fun HomeScreen(modifier: Modifier = Modifier, peerId: String, peers: List) { + fun myApp(peers: List, innerPadding: PaddingValues) { + + var navController = rememberNavController() + + NavHost(navController, startDestination = "home") { + composable("home") { + HomeScreen( + onNavigateToProfile = { navController.navigate("callScreen") }, + modifier = Modifier.padding(innerPadding), + peerId = "VR CLIENT", + peers = peers + ) + + // A simple loading/home screen + //Greeting() + } + composable("callScreen") { + CallScreen() + } + } + + } + + + + @RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM) + @Composable + fun HomeScreen(modifier: Modifier = Modifier, peerId: String, peers: List, onNavigateToProfile: () -> Unit) { val context = LocalContext.current + + val sessionManager = remember { SessionManager(context) } // Refresh UI every 3 seconds LaunchedEffect(peers) { @@ -253,7 +297,7 @@ class MainActivity : ComponentActivity() { Spacer(modifier = Modifier.height(8.dp)) - PeerIdSection(peerId) // Displays the correct Peer ID + PeerIdSection("VR CLIENT") // Displays the correct Peer ID Column( modifier = Modifier @@ -262,12 +306,14 @@ class MainActivity : ComponentActivity() { .verticalScroll(rememberScrollState()), horizontalAlignment = Alignment.CenterHorizontally ) { - OnlineNowSection(peers) // No need for additional state + OnlineNowSection(peers,onNavigateToProfile) // No need for additional state } NIHFormsButton() } } + + } suspend fun fetchUserId(): String { @@ -288,6 +334,9 @@ suspend fun fetchUserId(): String { } } + + + @RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM) @Composable fun Greeting( @@ -298,26 +347,18 @@ fun Greeting( @SuppressLint("RestrictedApi") cameraRequest: () -> CameraRequest, isInCall: Boolean ) { - val navController = rememberNavController() - LaunchedEffect(isInCall) { - if (isInCall) { - navController.navigate("callScreen") - } else { - navController.navigate("home") // Navigate back when call ends - } - } + +// LaunchedEffect(isInCall) { +// if (isInCall) { +// navController.navigate("callScreen") +// } else { +// navController.navigate("home") // Navigate back when call ends +// } +// } + - NavHost(navController, startDestination = "home") { - composable("home") { - // A simple loading/home screen - //Greeting() - } - composable("callScreen") { - CallScreen() - } - } } diff --git a/app/src/main/java/com/example/neurology_project_android/RoomClient.kt b/app/src/main/java/com/example/neurology_project_android/RoomClient.kt new file mode 100644 index 0000000..3cc8553 --- /dev/null +++ b/app/src/main/java/com/example/neurology_project_android/RoomClient.kt @@ -0,0 +1,301 @@ +package com.example.neurology_project_android + +import android.content.Context +import androidx.annotation.OptIn +import androidx.media3.common.util.Log +import androidx.media3.common.util.UnstableApi +import com.example.neurology_project_android.sampledata.Device +import com.google.gson.Gson +import com.google.gson.JsonArray +import com.google.gson.JsonObject +import io.socket.client.Ack +import io.socket.client.Socket + +import org.json.JSONArray +import org.json.JSONObject +import java.util.function.Consumer +import java.util.function.Function + +class RoomClient constructor(room_id: String, name: String, socket: Socket, context: Context) { + private var localMedia = null; + private var remoteMedia = null; + + private lateinit var socket: Socket + private lateinit var sendTransportId:String + + @OptIn(UnstableApi::class) + fun requestProducer(producerTransportId: String, kind: String, rtpParams: String){ + Log.d("REQUESTPRODUCER: ", "IN REQUEST PRODUCER") + val jsonPayload = JSONObject().apply { put("producerTransportId", producerTransportId) + put("kind", kind) + put("rtpParameters", JSONObject(rtpParams))} + + socket.emit("produce", jsonPayload, Ack { args -> + + // This block runs when the server executes 'callback(roomList)' + + if (args.isEmpty() || args[0] == null) { + Log.e("SIGNALING CLIENT", "no producer recv") + return@Ack + } + + // Assuming the room list is the first argument in the callback's arguments array + val responseData = args[0] + + if (responseData is JSONObject) { + + + Log.d("REQUEST PRODUCER", "SUCCESS! producer Recd $responseData") + // 1. Initialize a mutable list to hold the extracted room IDs +// val gson = Gson() +// val jsonObject:JsonObject = gson.fromJson(responseData.toString(), JsonObject::class.java) +// +// +//// +//// Device.load(responseData) +// +// Device.load(jsonObject) + + + + + + // TODO: Update ViewModel/Activity state here + + } else { + Log.e("SIGNALING CLIENT", "Received unexpected response type: ${responseData.javaClass.name}") + } + }) + } + + @OptIn(UnstableApi::class) + private fun requestConnectTransport( + t: JSONObject, + producerId: String + ) { + val jsonPayload = JSONObject().apply { put("transport_id", producerId) + put("dtlsParameters", t) + } + socket.emit("connectTransport", jsonPayload) + Log.d("ROOMCLIENT", "EMITTED CONNECT TRANSPORT" + t.toString()) + + + } + @OptIn(UnstableApi::class) + fun createWebRTCTransport(){ + val jsonPayload = JSONObject().apply { put("forceTcp", "false") + put("rtpCapabilities", Device.rtpCapabilities)} + socket.emit("createWebRtcTransport", jsonPayload, Ack { args -> + + // This block runs when the server executes 'callback(roomList)' + + if (args.isEmpty() || args[0] == null) { + Log.e("SIGNALING CLIENT", "no router rtpCaps") + return@Ack + } + + // Assuming the room list is the first argument in the callback's arguments array + val responseData = args[0] + + if (responseData is JSONObject) { + + + Log.d("SIGNALING CLIENT", "SUCCESS! transport created: $responseData") + // 1. Initialize a mutable list to hold the extracted room IDs + + + + val gson = Gson() + val iceParameters:JsonObject = gson.fromJson(responseData.get("iceParameters").toString(), JsonObject::class.java) + val iceCandidates: JSONArray = responseData.getJSONArray("iceCandidates") + val iceCandidatesJson: JsonArray = gson.fromJson(iceCandidates.toString(), JsonArray::class.java) + val dtlsParameters:JsonObject = gson.fromJson(responseData.get("dtlsParameters").toString(), JsonObject::class.java) +// +// Device.load(responseData) + val producerTransport = Device.createSendTransport(responseData.get("id").toString(), iceParameters, + iceCandidatesJson, dtlsParameters) + + sendTransportId = responseData.get("id").toString() + Log.d("ROOM CLIENT", "CREATED PRODUCER TRANSPORT: " + producerTransport.toString()) + + producerTransport.onProduce = object : Function { + override fun apply(pData: JSONObject): String { + Log.d("ROOM CLIENT", "IN ONPRODUCE") + // The 'apply' method is the Single Abstract Method (SAM) that must be implemented + Log.d("ROOM CLIENT", "IN PRODUCER TRANSPORT ONPRODUCE" + pData.toString()) + var rtpParams = pData.get("rtpParameters").toString() + + var kind = pData.get("kind").toString() + + requestProducer(sendTransportId, kind, rtpParams) + return "hi" + } + } + + producerTransport.transport.onConnect = Consumer {dtlsParameters: JSONObject -> + + Log.d("ROOM CLIENT", "ON CONNECT" + dtlsParameters.toString()) + + requestConnectTransport(dtlsParameters, sendTransportId) + + + } + + + + + var localVideoSource = Device.createVideoSource() + producerTransport.send(localVideoSource) + var producerId = producerTransport.onProduce.apply(JSONObject(producerTransport.produceData.toString())) + Log.d("producerIDasdfsdaf", producerId.toString()) + + + // TODO: Update ViewModel/Activity state here + + } else { + Log.e("SIGNALING CLIENT", "Received unexpected response type: ${responseData.javaClass.name}") + } + }) + + + + + } + + @OptIn(UnstableApi::class) + fun getRouterRtpCapabilities(){ + val jsonPayload = JSONObject().apply { } + socket.emit("getRouterRtpCapabilities", jsonPayload, Ack { args -> + + // This block runs when the server executes 'callback(roomList)' + + if (args.isEmpty() || args[0] == null) { + Log.e("SIGNALING CLIENT", "no router rtpCaps") + return@Ack + } + + // Assuming the room list is the first argument in the callback's arguments array + val responseData = args[0] + + if (responseData is JSONObject) { + + + Log.d("SIGNALING CLIENT", "SUCCESS! routerRTCCapabilities Recv $responseData") + // 1. Initialize a mutable list to hold the extracted room IDs + val gson = Gson() + val jsonObject:JsonObject = gson.fromJson(responseData.toString(), JsonObject::class.java) + + +// +// Device.load(responseData) + + Device.load(jsonObject) + + + + + + // TODO: Update ViewModel/Activity state here + + } else { + Log.e("SIGNALING CLIENT", "Received unexpected response type: ${responseData.javaClass.name}") + } + }) + + } + + + @OptIn(UnstableApi::class) + fun joinRoom(room_id: String, name: String){ + + val jsonPayload = JSONObject().apply { put("room_id", room_id) + put("name", name)} + socket.emit("join", jsonPayload, Ack { args -> + + // This block runs when the server executes 'callback(roomList)' + + if (args.isEmpty() || args[0] == null) { + Log.e("SIGNALING CLIENT", "no room joined") + return@Ack + } + + // Assuming the room list is the first argument in the callback's arguments array + val responseData = args[0] + + if (responseData is JSONObject) { + + + Log.d("SIGNALING CLIENT", "SUCCESS! Room joined: $responseData") + // 1. Initialize a mutable list to hold the extracted room IDs + + + + + + + + // TODO: Update ViewModel/Activity state here + + } else { + Log.e("SIGNALING CLIENT", "Received unexpected response type: ${responseData.javaClass.name}") + } + }) + getRouterRtpCapabilities() + createWebRTCTransport() + } + @OptIn(UnstableApi::class) + fun createRoom(room_id: String){ + val emptyPayload = JSONObject() + + socket.emit("createRoom", { room_id}, Ack { args -> + + // This block runs when the server executes 'callback(roomList)' + + if (args.isEmpty() || args[0] == null) { + Log.e("SIGNALING CLIENT", "no room created") + return@Ack + } + + // Assuming the room list is the first argument in the callback's arguments array + val responseData = args[0] + + if (responseData is JSONArray) { + + + Log.d("SIGNALING CLIENT", "SUCCESS! Room List received: $responseData") + // 1. Initialize a mutable list to hold the extracted room IDs + + var roomList = mutableListOf() + + // 2. Loop through the JSONArray + for (i in 0 until responseData.length()) { + // 3. Safely extract each element as a String + val roomId = responseData.getString(i) + roomList.add(roomId) + + } + + + + + // TODO: Update ViewModel/Activity state here + + } else { + Log.e("SIGNALING CLIENT", "Received unexpected response type: ${responseData.javaClass.name}") + } + }) + } + + private var device = Device() + init { + this.socket = socket + Device.initialize(context) + + + + joinRoom(room_id, "david_android") + + } + + +} \ No newline at end of file diff --git a/app/src/main/java/com/example/neurology_project_android/SignalingClient.kt b/app/src/main/java/com/example/neurology_project_android/SignalingClient.kt index 6e39b7e..b279430 100644 --- a/app/src/main/java/com/example/neurology_project_android/SignalingClient.kt +++ b/app/src/main/java/com/example/neurology_project_android/SignalingClient.kt @@ -3,295 +3,70 @@ package com.example.neurology_project_android import android.content.Context import android.hardware.camera2.CameraManager import android.media.AudioManager +import android.media.metrics.Event import android.os.Build import androidx.annotation.OptIn import androidx.annotation.RequiresApi +import androidx.compose.runtime.remember import androidx.media3.common.util.Log import androidx.media3.common.util.UnstableApi +import io.socket.client.Ack +import io.socket.client.IO +import io.socket.client.Manager +import io.socket.client.Socket import okhttp3.FormBody import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.Response import okhttp3.WebSocket import okhttp3.WebSocketListener +import one.dugon.mediasoup_android_sdk.Engine +import one.dugon.mediasoup_android_sdk.Engine.Listener +import org.json.JSONArray import org.json.JSONObject -import org.webrtc.Camera2Capturer -import org.webrtc.CameraVideoCapturer.CameraEventsHandler -import org.webrtc.DataChannel -import org.webrtc.DefaultVideoDecoderFactory -import org.webrtc.DefaultVideoEncoderFactory -import org.webrtc.EglBase -import org.webrtc.IceCandidate -import org.webrtc.MediaConstraints -import org.webrtc.MediaStream -import org.webrtc.PeerConnection -import org.webrtc.PeerConnectionFactory -import org.webrtc.SdpObserver -import org.webrtc.SessionDescription -import org.webrtc.SurfaceTextureHelper -import org.webrtc.VideoTrack -import org.webrtc.VideoProcessor -import org.webrtc.VideoSource +import kotlin.concurrent.fixedRateTimer +import kotlin.contracts.contract + +//import org.webrtc.Camera2Capturer +//import org.webrtc.CameraVideoCapturer.CameraEventsHandler +//import org.webrtc.DataChannel +//import org.webrtc.DefaultVideoDecoderFactory +//import org.webrtc.DefaultVideoEncoderFactory +//import org.webrtc.EglBase +//import org.webrtc.IceCandidate +//import org.webrtc.MediaConstraints +//import org.webrtc.MediaStream +//import org.webrtc.PeerConnection +//import org.webrtc.PeerConnectionFactory +//import org.webrtc.SdpObserver +//import org.webrtc.SessionDescription +//import org.webrtc.SurfaceTextureHelper +//import org.webrtc.VideoTrack +//import org.webrtc.VideoProcessor +//import org.webrtc.VideoSource @RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM) class SignalingClient @OptIn(UnstableApi::class) constructor ( - url: String, - context: Context, ourID: String, - private val onCallRecieved: () -> Unit, - private val onCallEnded: () -> Unit + context: Context, + private val onPeersFetched: (List) -> Unit + ) { - private lateinit var localPeer: PeerConnection +// private lateinit var localPeer: PeerConnection private lateinit var httpUrl: String private lateinit var theirID: String - private var ourID = ourID + private lateinit var context: Context private lateinit var webSocketListener: WebSocketListener private lateinit var client: OkHttpClient private lateinit var mediaID: String private lateinit var webSocket: WebSocket - private lateinit var localSDP: SessionDescription - private lateinit var track: VideoTrack - private val server = - PeerConnection.IceServer.builder("stun:stun.l.google.com:19302").createIceServer() - private var candidatesList = ArrayList() +// private lateinit var localSDP: SessionDescription +// private lateinit var track: VideoTrack +// private val server = +// PeerConnection.IceServer.builder("stun:stun.l.google.com:19302").createIceServer() +// private var candidatesList = ArrayList() private var isReadyToAddIceCandidate: Boolean = false private var candidateMessagesToSend = ArrayList() - private lateinit var camera1Capturer: Camera2Capturer - private lateinit var videoSource: VideoSource - private lateinit var factory: PeerConnectionFactory - private lateinit var rootEGL: EglBase - private lateinit var sdpObserver: SdpObserver - - fun setLocalSDP() { - localPeer.setLocalDescription(remoteObserver, localSDP) - } - - private var remoteObserver = object : SdpObserver { - @OptIn(UnstableApi::class) - override fun onCreateSuccess(sdp: SessionDescription?) { - Log.d("RemoteObserver", "Answer SDP was Created") - if (sdp != null) { - localSDP = sdp - setLocalSDP() - } - val sdpMsg = JSONObject() - sdpMsg.accumulate("type", "answer") - sdpMsg.accumulate( - "sdp", - sdp?.description - ) - - - val payload = JSONObject() - payload.accumulate("sdp", sdpMsg) - payload.accumulate("type", "media") - payload.accumulate("browser", "firefox") - payload.accumulate("connectionId", mediaID) - - val msg = JSONObject() - msg.accumulate("type", "ANSWER") - msg.accumulate("payload", payload) - msg.accumulate("dst", theirID) - - val formBody = - FormBody.Builder().add("type", "ANSWER").add("payload", payload.toString()) - .add("dst", theirID).build() - - - var request = Request.Builder().url(httpUrl).post(formBody).build() - - webSocket.send(msg.toString()) - - for (candidate in candidatesList) { - val status = localPeer.addIceCandidate(candidate) - Log.d("Adding ICE CANDIDATE", status.toString()) - } - - isReadyToAddIceCandidate = true - - } - - @OptIn(UnstableApi::class) - override fun onSetSuccess() { - Log.d("SignalingClient", "RemoteSDP set successfully") - - val mediaConstraints1 = MediaConstraints.KeyValuePair( - "kRTCMediaConstraintsOfferToReceiveAudio", - "kRTCMediaConstraintsValueTrue" - ) - val mediaConstraints2 = MediaConstraints.KeyValuePair( - "kRTCMediaConstraintsOfferToReceiveVideo", - "kRTCMediaConstraintsValueTrue" - ) - val mediaConstraints3 = MediaConstraints.KeyValuePair( - "kRTCMediaStreamTrackKindVideo", - "kRTCMediaConstraintsValueTrue" - ) - - val mediaConstraints4 = MediaConstraints.KeyValuePair( - "DtlsSrtpKeyAgreement", - "kRTCMediaConstraintsValueTrue" - ) - - val mediaConstraints5 = MediaConstraints.KeyValuePair("setup", "actpass") - val mediaConstraints6 = MediaConstraints.KeyValuePair( - "video", - "true" - ) - val mediaConstraints = MediaConstraints() - - - mediaConstraints.mandatory.add(mediaConstraints1) - mediaConstraints.mandatory.add(mediaConstraints2) - mediaConstraints.mandatory.add(mediaConstraints3) - mediaConstraints.mandatory.add(mediaConstraints4) - mediaConstraints.mandatory.add(mediaConstraints5) - mediaConstraints.mandatory.add(mediaConstraints6) - localPeer.createAnswer(this, mediaConstraints) - } - - @OptIn(UnstableApi::class) - override fun onCreateFailure(error: String?) { - Log.d("OnCreateFailure", error.toString()) - } - - @OptIn(UnstableApi::class) - override fun onSetFailure(error: String?) { - Log.d("SDP Observer", error.toString()) - } - - } - - private var peerConnObserver = object : PeerConnection.Observer { - @OptIn(UnstableApi::class) - override fun onSignalingChange(p0: PeerConnection.SignalingState?) { - if (p0 != null) { - Log.d("Signaling State", "SignalingChange " + p0.name) - } - - - } - - @OptIn(UnstableApi::class) - override fun onIceConnectionChange(p0: PeerConnection.IceConnectionState?) { - Log.d("ICE Connection", p0.toString()) - - if (p0 == PeerConnection.IceConnectionState.DISCONNECTED || - p0 == PeerConnection.IceConnectionState.CLOSED) { - onCallEnded() // Notify MainActivity when the call ends - } - } - - override fun onIceConnectionReceivingChange(p0: Boolean) { - //TODO("Not yet implemented") - } - - @OptIn(UnstableApi::class) - override fun onIceGatheringChange(p0: PeerConnection.IceGatheringState?) { - Log.d("ICEGATHERINGSTATE", p0.toString()) - } - - @OptIn(UnstableApi::class) - override fun onIceCandidate(p0: IceCandidate?) { - val candidate = JSONObject() - candidate.accumulate("candidate", p0!!.sdp) - candidate.accumulate("sdpMLineIndex", p0.sdpMLineIndex) - candidate.accumulate("sdpMid", p0.sdpMid) - - val payload = JSONObject() - payload.accumulate("candidate", candidate) - payload.accumulate("connectionId", mediaID) - payload.accumulate("type", "media") - - val message = JSONObject() - message.accumulate("payload", payload) - message.accumulate("type", "CANDIDATE") - message.accumulate("dst", theirID) - - webSocket.send(message.toString()) - - Log.d("REC ICECandidate", p0.toString()) - } - - override fun onIceCandidatesRemoved(p0: Array?) { - //TODO("Not yet implemented") - } - - @OptIn(UnstableApi::class) - override fun onAddStream(p0: MediaStream?) { - - if (p0 != null) { - // Handle remote video track - if (p0.videoTracks.isNotEmpty()) { - val remoteVideoTrack = p0.videoTracks[0] - //localPeer.addTrack(remoteVideoTrack) - // You need a way to pass this remoteVideoTrack to your UI - // For example, if you have a SurfaceViewRenderer in your Activity/Fragment: - // yourRemoteVideoRenderer.addTrack(remoteVideoTrack) - Log.d("PeerConnection", "Remote Video Track received: ${remoteVideoTrack.id()}") - } - // Handle remote audio track (WebRTC usually plays this automatically once received) - if (p0.audioTracks.isNotEmpty()) { - val remoteAudioTrack = p0.audioTracks[0] - //localPeer.addTrack(remoteAudioTrack) - Log.d("PeerConnection", "Remote Audio Track received: ${remoteAudioTrack.id()}") - } - } - - } - - override fun onRemoveStream(p0: MediaStream?) { - //TODO("Not yet implemented") - } - - @OptIn(UnstableApi::class) - override fun onDataChannel(p0: DataChannel?) { - - Log.d("PeerConnection", "DataChannel added") - - //TODO("Not yet implemented") - } - - @OptIn(UnstableApi::class) - override fun onRenegotiationNeeded() { - Log.d("PeerConnection", "Renegotation Needed") - - } - - } - - private var cameraEventsHandler = object : CameraEventsHandler { - @OptIn(UnstableApi::class) - override fun onCameraError(p0: String?) { - Log.d("CAMERA ERROR", p0!!) - //TODO("Not yet implemented") - } - - override fun onCameraDisconnected() { - //TODO("Not yet implemented") - } - - override fun onCameraFreezed(p0: String?) { - - } - - @OptIn(UnstableApi::class) - override fun onCameraOpening(p0: String?) { - Log.d("CAMERA EVENTS", "CAMERA OPEN") - //TODO("Not yet implemented") - } - - @OptIn(UnstableApi::class) - override fun onFirstFrameAvailable() { - Log.d("CAMERA", "FIRST FRAME") - } - - @OptIn(UnstableApi::class) - override fun onCameraClosed() { - Log.d("CAMERA","Camera Closed") - } - - } val availabilityCallback = object : CameraManager.AvailabilityCallback() { @OptIn(UnstableApi::class) @@ -315,24 +90,6 @@ class SignalingClient @OptIn(UnstableApi::class) constructor - private fun generateConfig(): PeerConnection.RTCConfiguration { - val config = PeerConnection.RTCConfiguration(listOf(server)) - config.sdpSemantics = PeerConnection.SdpSemantics.UNIFIED_PLAN - - config.continualGatheringPolicy = PeerConnection.ContinualGatheringPolicy.GATHER_ONCE - config.iceTransportsType = PeerConnection.IceTransportsType.ALL - return config - } - - private fun buildFactory(rootEGL: EglBase): PeerConnectionFactory? { - - val encoderFactory = DefaultVideoEncoderFactory(rootEGL.eglBaseContext, true, true) - val decoderFactory = DefaultVideoDecoderFactory(rootEGL.eglBaseContext) - val factory = PeerConnectionFactory.builder().setVideoDecoderFactory(decoderFactory) - .setVideoEncoderFactory(encoderFactory).createPeerConnectionFactory() - return factory - } - @@ -340,12 +97,6 @@ class SignalingClient @OptIn(UnstableApi::class) constructor private fun buildVideoSenders(context: Context, url: String) { - val options = PeerConnectionFactory.InitializationOptions.builder(context) - .createInitializationOptions() - PeerConnectionFactory.initialize(options) - rootEGL = EglBase.create() - factory = buildFactory(rootEGL)!! - val cameraManager = context.getSystemService(Context.CAMERA_SERVICE) as CameraManager cameraManager.registerAvailabilityCallback(availabilityCallback, null) @@ -356,328 +107,114 @@ class SignalingClient @OptIn(UnstableApi::class) constructor val cameraList = cameraManager.cameraIdList val camera01 = cameraManager.cameraIdList.first() val camera02 = cameraManager.cameraIdList.last() - camera1Capturer = Camera2Capturer(context, camera02, cameraEventsHandler) - videoSource = factory?.createVideoSource(true)!! - audioManager.toString() -// -// - val surfaceTexture2 = SurfaceTextureHelper.create("CaptureThread", rootEGL.eglBaseContext) -// //var test = MultiMediaClient() - camera1Capturer.initialize(surfaceTexture2, context, videoSource!!.capturerObserver) - val config2 = generateConfig() - -// - localPeer = factory.createPeerConnection(config2, peerConnObserver)!! - camera1Capturer.startCapture(1920, 1080, 30) + client = OkHttpClient().newBuilder().build() httpUrl = url AudioManager.ADJUST_UNMUTE val audioDeviceInfo = audioManager.getDevices(AudioManager.GET_DEVICES_INPUTS) Log.d("Signaling Client", "Audio devices" + audioDeviceInfo.size) - val mediaConstraints1 = MediaConstraints.KeyValuePair( - "kRTCMediaConstraintsOfferToReceiveAudio", - "kRTCMediaConstraintsValueTrue" - ) - val mediaConstraints2 = MediaConstraints.KeyValuePair( - "kRTCMediaConstraintsOfferToReceiveVideo", - "kRTCMediaConstraintsValueTrue" - ) - val mediaConstraints3 = MediaConstraints.KeyValuePair( - "kRTCMediaStreamTrackKindVideo", - "kRTCMediaConstraintsValueTrue" - ) - - val mediaConstraints4 = MediaConstraints.KeyValuePair( - "DtlsSrtpKeyAgreement", - "kRTCMediaConstraintsValueTrue" - ) - - val mediaConstraints5 = MediaConstraints.KeyValuePair("setup", "actpass") - val mediaConstraints6 = MediaConstraints.KeyValuePair( - "video", - "true" - ) - val mediaConstraints = MediaConstraints() - - - mediaConstraints.mandatory.add(mediaConstraints1) - mediaConstraints.mandatory.add(mediaConstraints2) - mediaConstraints.mandatory.add(mediaConstraints3) - mediaConstraints.mandatory.add(mediaConstraints4) - mediaConstraints.mandatory.add(mediaConstraints5) - mediaConstraints.mandatory.add(mediaConstraints6) - - val audioSource = factory.createAudioSource(mediaConstraints) - - val audioTrack = factory.createAudioTrack("0001", audioSource) - track = factory.createVideoTrack("0001", videoSource) - - localPeer.addTrack(track, listOf("track01")) - - localPeer.addTrack(audioTrack, listOf("track01")) - } - fun changeCamera(){ - camera1Capturer.switchCamera(null) } - @OptIn(UnstableApi::class) - fun changeVideoSource(videoProcessor: VideoProcessor){ - //var frame = VideoFrame() - //videoSource.capturerObserver.onCapturerStarted(true) + fun joinRoom(room_id: String){ + var currentRoomClient = RoomClient(room_id, "david_android", socket, context = this.context) } - fun getVideoSource(): VideoSource { - return videoSource - } - fun createAndSendCallMessage(p0: SessionDescription?, userId: String, mediaID: String) { - var innerSDPMessage = JSONObject() - innerSDPMessage.put("sdp", p0!!.description) - innerSDPMessage.put("type", "offer") - var payloadMessage = JSONObject() - payloadMessage.put("connectionId", mediaID) - payloadMessage.put("type", "media") - payloadMessage.put("sdp", innerSDPMessage) - - var outerObjectMessage = JSONObject() - outerObjectMessage.put("dst", userId) - outerObjectMessage.put("src", ourID) - outerObjectMessage.put("payload", payloadMessage) - outerObjectMessage.put("type", "OFFER") - - webSocket.send(outerObjectMessage.toString()) + fun getAuthToken(){ + } + private var rooms: Array = emptyArray() + private var roomList = mutableListOf() + private lateinit var socket: Socket; - //function to begin calling - fun startCall(userId: String) { - theirID = userId - - val mediaConstraints1 = MediaConstraints.KeyValuePair( - "kRTCMediaConstraintsOfferToReceiveAudio", - "kRTCMediaConstraintsValueTrue" - ) - val mediaConstraints2 = MediaConstraints.KeyValuePair( - "kRTCMediaConstraintsOfferToReceiveVideo", - "kRTCMediaConstraintsValueTrue" - ) - val mediaConstraints3 = MediaConstraints.KeyValuePair( - "kRTCMediaStreamTrackKindVideo", - "kRTCMediaConstraintsValueTrue" - ) - - val mediaConstraints4 = MediaConstraints.KeyValuePair( - "DtlsSrtpKeyAgreement", - "kRTCMediaConstraintsValueTrue" - ) - - val mediaConstraints5 = MediaConstraints.KeyValuePair("setup", "actpass") - val mediaConstraints6 = MediaConstraints.KeyValuePair( - "video", - "true" - ) - val mediaConstraints = MediaConstraints() - - - mediaConstraints.mandatory.add(mediaConstraints1) - mediaConstraints.mandatory.add(mediaConstraints2) - mediaConstraints.mandatory.add(mediaConstraints3) - mediaConstraints.mandatory.add(mediaConstraints4) - mediaConstraints.mandatory.add(mediaConstraints5) - mediaConstraints.mandatory.add(mediaConstraints6) - mediaID = "31480asdf33" - sdpObserver = object: SdpObserver { - @OptIn(UnstableApi::class) - override fun onCreateSuccess(p0: SessionDescription?) { - Log.d("SignalingClient", "createSDP Success") - createAndSendCallMessage(p0, userId,mediaID) - var sdpObserver2 = object: SdpObserver { - override fun onCreateSuccess(p0: SessionDescription?) { - Log.d("SignalingClient", "onCreateSucess") - } - - - override fun onSetSuccess() { - Log.d("SignalingClient", "Set Peer Success") - isReadyToAddIceCandidate = true - } - - override fun onCreateFailure(p0: String?) { - //TODO("Not yet implemented") - } - - override fun onSetFailure(p0: String?) { - //TODO("Not yet implemented") - } - } - localPeer.setLocalDescription(sdpObserver2, p0) + @OptIn(UnstableApi::class) + fun getRoomList(){ + Log.d("SIGNALING CLIENT", "Attempting to emit getRoomList with Ack") + // 1. Prepare the payload (empty, as the server doesn't need it for this event) + val emptyPayload = JSONObject() // Or simply passing 'null' might work, but this is safer - } + // 2. Emit the event with two arguments: Payload + Ack Callback + socket.emit("getRoomList", emptyPayload, Ack { args -> - @OptIn(UnstableApi::class) - override fun onSetSuccess() { - Log.d("SignalingClient", "Remote SDP Call set Success") - } + // This block runs when the server executes 'callback(roomList)' - override fun onCreateFailure(p0: String?) { - //TODO("Not yet implemented") + if (args.isEmpty() || args[0] == null) { + Log.e("SIGNALING CLIENT", "No room list received.") + return@Ack } - override fun onSetFailure(p0: String?) { - //TODO("Not yet implemented") - } + // Assuming the room list is the first argument in the callback's arguments array + val responseData = args[0] - } + if (responseData is JSONArray) { - var offerSDP = localPeer.createOffer(sdpObserver, mediaConstraints) - //localPeer.setLocalDescription(sdpObserver) - } + Log.d("SIGNALING CLIENT", "SUCCESS! Room List received: $responseData") + // 1. Initialize a mutable list to hold the extracted room IDs - init { - buildVideoSenders(context, url) + var roomList = mutableListOf() - var videoCamera = VideoCameraSetup(context, localPeer, factory, rootEGL) - var sessionManager = SessionManager(context) - val authToken = sessionManager.fetchAuthToken() - - if (httpUrl != null) { - Log.d("SignalingClient", "URL IS $httpUrl") - Log.d("SignalingClient", "isHttps?: " + httpUrl) - val request = Request.Builder().url(httpUrl).addHeader("Authorization", - authToken.toString() - ).build() - Log.d("SignalingClient", "auth token" + authToken.toString()) - Log.d("SignalingClient", request.method) - - - // Create the WebSocket listener - webSocketListener = object : WebSocketListener() { - override fun onOpen(webSocket: WebSocket, response: Response) { - super.onOpen(webSocket, response) - Log.d("SignalingClient", "WebSocket opened: ${response.message}") + // 2. Loop through the JSONArray + for (i in 0 until responseData.length()) { + // 3. Safely extract each element as a String + val roomId = responseData.getString(i) + roomList.add(roomId) + onPeersFetched(roomList) } - override fun onMessage(webSocket: WebSocket, text: String) { - super.onMessage(webSocket, text) - Log.d("SignalingClient", "Message received: $text") - val jsonMessage = JSONObject(text) - if (text.contains("OFFER")) { - theirID = jsonMessage.get("src").toString() - onCallRecieved() - } - if (text.contains("OFFER") && text.contains("media")) { - - Log.d("SignalingClient", "We received an OFFER") - //val messageSplit = text.split(",") - Log.d("Signaling client", jsonMessage.get("payload").toString()) - theirID = jsonMessage.get("src").toString() - - Log.d("Signaling Client", "TheirID: $theirID") - val payload = jsonMessage.get("payload") as JSONObject - val sdpMessage = payload.get("sdp") as JSONObject - mediaID = payload.get("connectionId").toString() - Log.d("Signaling Client", "mediaID: $mediaID") - val theirSDP = sdpMessage.get("sdp").toString() - val sessionDescription = - SessionDescription(SessionDescription.Type.OFFER, theirSDP) - Log.d("signaling client", "sdp is: $theirSDP") - localPeer.setRemoteDescription(remoteObserver, sessionDescription) - - //Log.d("Signaling Client", "set remote SDP " + localPeer.toString()) - } - - if (text.contains("ANSWER") && text.contains("media")) { - - Log.d("SignalingClient", "We received an ANSWER") - //val messageSplit = text.split(",") - Log.d("Signaling client", jsonMessage.get("payload").toString()) - theirID = jsonMessage.get("src").toString() - - Log.d("Signaling Client", "TheirID: $theirID") - val payload = jsonMessage.get("payload") as JSONObject - val sdpMessage = payload.get("sdp") as JSONObject - mediaID = payload.get("connectionId").toString() - Log.d("Signaling Client", "mediaID: $mediaID") - val theirSDP = sdpMessage.get("sdp").toString() - val sessionDescription = - SessionDescription(SessionDescription.Type.ANSWER, theirSDP) - Log.d("signaling client", "sdp is: $theirSDP") - Log.d("SignalingClient", "Setting our reemote SDP To their answer") - localPeer.setRemoteDescription(sdpObserver, sessionDescription) - - //Log.d("Signaling Client", "set remote SDP " + localPeer.toString()) - } - - if (text.contains("CANDIDATE")) { - val payload = jsonMessage.get("payload") as JSONObject - Log.d("Payload Message", payload.toString()) - val candidateMsg = payload.get("candidate") as JSONObject - Log.d( - "CANDIDATE MESSAGE", - "Candidate variable contains: $candidateMsg" - ) - val sdpMid = candidateMsg.get("sdpMid").toString() - val sdpMLineIndex = candidateMsg.get("sdpMLineIndex").toString() - val candidate = candidateMsg.get("candidate").toString() - val iceCandidate = IceCandidate(sdpMid, sdpMLineIndex.toInt(), candidate) - - val msg = JSONObject() - msg.accumulate("type", "CANDIDATE") - msg.accumulate("payload", payload) - msg.accumulate("dst", theirID) - - - - if (!isReadyToAddIceCandidate) { - candidatesList.add(iceCandidate) - candidateMessagesToSend.add(msg.toString()) - Log.d("Signaling client", "Adding ICE CANDDIATE TO LIST") - } else { - Log.d( - "IceCANDIDATE", - localPeer.addIceCandidate(iceCandidate).toString() - ) - //webSocket.send(msg.toString()) - } - - - } - } - override fun onClosing(webSocket: WebSocket, code: Int, reason: String) { - super.onClosing(webSocket, code, reason) - Log.d("SignalingClient", "WebSocket closing: $reason (code $code)") - } - override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { - super.onClosed(webSocket, code, reason) - Log.d("SignalingClient", "WebSocket closed: $reason (code $code)") - } + // TODO: Update ViewModel/Activity state here - override fun onFailure( - webSocket: WebSocket, - t: Throwable, - response: Response? - ) { - super.onFailure(webSocket, t, response) - Log.e("SignalingClient", "WebSocket failure: ${t.message}") - } + } else { + Log.e("SIGNALING CLIENT", "Received unexpected response type: ${responseData.javaClass.name}") } - webSocket = client.newWebSocket(request, webSocketListener) + }) - } else { - Log.d("Signaling Client", "url is null") + } + + + init { + this.context = context + var sessionManager = SessionManager(context) + var token = sessionManager.fetchAuthToken() + Log.d("SIGNALING CLIENT", "token is " + token) + val authMap = mapOf("cookie" to listOf(token)) + + + + + val options = IO.Options.builder() + .setReconnection(true) + .setForceNew(true) + .setExtraHeaders(authMap) + + .build() + + + try{ + socket = IO.socket("https://meechie.techkit.xyz:3016", options) + + + }catch(e: Error){ + Log.d("SOCKET ERROR:" ,e.toString()) + } + + socket.connect() + fixedRateTimer("getRoomList timer", false, 0L, 175000L) { + getRoomList() } + } } \ No newline at end of file diff --git a/app/src/main/java/com/example/neurology_project_android/VideoCameraSetup.kt b/app/src/main/java/com/example/neurology_project_android/VideoCameraSetup.kt index ba39108..728dc12 100644 --- a/app/src/main/java/com/example/neurology_project_android/VideoCameraSetup.kt +++ b/app/src/main/java/com/example/neurology_project_android/VideoCameraSetup.kt @@ -1,167 +1,167 @@ -package com.example.neurology_project_android - -import android.annotation.SuppressLint -import android.content.Context -import android.hardware.usb.UsbDevice -import androidx.annotation.OptIn -import androidx.camera.core.imagecapture.CameraRequest -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import androidx.media3.common.util.Log -import androidx.media3.common.util.UnstableApi -import org.webrtc.CameraVideoCapturer -import org.webrtc.CapturerObserver -import org.webrtc.EglBase -import org.webrtc.NV21Buffer -import org.webrtc.PeerConnection -import org.webrtc.PeerConnectionFactory -import org.webrtc.SurfaceTextureHelper -import org.webrtc.VideoCapturer -import org.webrtc.VideoFrame -import org.webrtc.VideoProcessor -import org.webrtc.VideoSink -import org.webrtc.VideoSource -import java.nio.ByteBuffer - -class VideoCameraSetup constructor( - context: Context, - localPeer: PeerConnection, - factory: PeerConnectionFactory, - rootEGL1: EglBase -){ - - - @SuppressLint("RestrictedApi") - private lateinit var cameraRequest: CameraRequest - private lateinit var videoProcessor: VideoProcessor - private lateinit var videoSource: VideoSource - private lateinit var capturerObserver: CapturerObserver - private var rootEGL = rootEGL1 - - private var localPeer = localPeer - private var factory = factory - - private var cameraInitialized by mutableStateOf(false) - - init { - prepareVideoDevices(context) - - - } - - fun prepareVideoDevices(context: Context){ - - val videoCapturerObserver = object: CapturerObserver { - @OptIn(UnstableApi::class) - override fun onCapturerStarted(p0: Boolean) { - - Log.e("Capturer Observer", "Capture Started") - } - - override fun onCapturerStopped() { - TODO("Not yet implemented") - } - - override fun onFrameCaptured(p0: VideoFrame?) { - - - //Log.e("Capturer Observer", "Frame Captured") - } - - } - - - val videoCapturer = object: VideoCapturer { - @OptIn(UnstableApi::class) - override fun initialize( - p0: SurfaceTextureHelper?, - p1: Context?, - p2: CapturerObserver? - ) { - Log.e("Video Capturer", "Initialized") - } - - @OptIn(UnstableApi::class) - override fun startCapture(p0: Int, p1: Int, p2: Int) { - Log.e("VIDEO CAPTURER" , "Start Capture") - - } - - override fun stopCapture() { - TODO("Not yet implemented") - } - - override fun changeCaptureFormat( - p0: Int, - p1: Int, - p2: Int - ) { - TODO("Not yet implemented") - } - - override fun dispose() { - TODO("Not yet implemented") - } - - override fun isScreencast(): Boolean { - TODO("Not yet implemented") - } - - } - - videoProcessor = object: VideoProcessor { - override fun onCapturerStarted(p0: Boolean) { - TODO("Not yet implemented") - } - - override fun onCapturerStopped() { - TODO("Not yet implemented") - } - - override fun onFrameCaptured(p0: VideoFrame?) { - TODO("Not yet implemented") - } - - override fun setSink(p0: VideoSink?) { - TODO("Not yet implemented") - } - - } - - var timeStampNS: Long - var n21Buffer: NV21Buffer - var videoFrame: VideoFrame - - - - sendVideoCapturer(videoCapturer, context, videoCapturerObserver, localPeer, factory ) - } - - - fun sendVideoCapturer(capturer: VideoCapturer, context: Context, capturerObserver: CapturerObserver, localPeer: PeerConnection, factory: PeerConnectionFactory) { - - //localPeer = factory.createPeerConnection(config, peerConnObserver)!! - - - val videoSource2 = factory.createVideoSource(true) - val surfaceTexture = SurfaceTextureHelper.create("CaptureThread", rootEGL.eglBaseContext) - //capturerObserver.onCapturerStarted(true) - //capturer.initialize(surfaceTexture,context , capturerObserver) - - - //videoSource2.setVideoProcessor(videoProcessor) - //videoProcessor.onCapturerStarted(true) - //capturer.startCapture(1080, 720, 30) - - var videoTrack = factory.createVideoTrack("0001", videoSource2) - //videoSource = videoSource2 - - //localPeer.addTrack(videoTrack, listOf("track01")) - - - - //camera.initialize(surfaceTexture,context , capturerObserver) - - } -} \ No newline at end of file +//package com.example.neurology_project_android +// +//import android.annotation.SuppressLint +//import android.content.Context +//import android.hardware.usb.UsbDevice +//import androidx.annotation.OptIn +//import androidx.camera.core.imagecapture.CameraRequest +//import androidx.compose.runtime.getValue +//import androidx.compose.runtime.mutableStateOf +//import androidx.compose.runtime.setValue +//import androidx.media3.common.util.Log +//import androidx.media3.common.util.UnstableApi +//import org.webrtc.CameraVideoCapturer +//import org.webrtc.CapturerObserver +//import org.webrtc.EglBase +//import org.webrtc.NV21Buffer +//import org.webrtc.PeerConnection +//import org.webrtc.PeerConnectionFactory +//import org.webrtc.SurfaceTextureHelper +//import org.webrtc.VideoCapturer +//import org.webrtc.VideoFrame +//import org.webrtc.VideoProcessor +//import org.webrtc.VideoSink +//import org.webrtc.VideoSource +//import java.nio.ByteBuffer +// +//class VideoCameraSetup constructor( +// context: Context, +// localPeer: PeerConnection, +// factory: PeerConnectionFactory, +// rootEGL1: EglBase +//){ +// +// +// @SuppressLint("RestrictedApi") +// private lateinit var cameraRequest: CameraRequest +// private lateinit var videoProcessor: VideoProcessor +// private lateinit var videoSource: VideoSource +// private lateinit var capturerObserver: CapturerObserver +// private var rootEGL = rootEGL1 +// +// private var localPeer = localPeer +// private var factory = factory +// +// private var cameraInitialized by mutableStateOf(false) +// +// init { +// prepareVideoDevices(context) +// +// +// } +// +// fun prepareVideoDevices(context: Context){ +// +// val videoCapturerObserver = object: CapturerObserver { +// @OptIn(UnstableApi::class) +// override fun onCapturerStarted(p0: Boolean) { +// +// Log.e("Capturer Observer", "Capture Started") +// } +// +// override fun onCapturerStopped() { +// TODO("Not yet implemented") +// } +// +// override fun onFrameCaptured(p0: VideoFrame?) { +// +// +// //Log.e("Capturer Observer", "Frame Captured") +// } +// +// } +// +// +// val videoCapturer = object: VideoCapturer { +// @OptIn(UnstableApi::class) +// override fun initialize( +// p0: SurfaceTextureHelper?, +// p1: Context?, +// p2: CapturerObserver? +// ) { +// Log.e("Video Capturer", "Initialized") +// } +// +// @OptIn(UnstableApi::class) +// override fun startCapture(p0: Int, p1: Int, p2: Int) { +// Log.e("VIDEO CAPTURER" , "Start Capture") +// +// } +// +// override fun stopCapture() { +// TODO("Not yet implemented") +// } +// +// override fun changeCaptureFormat( +// p0: Int, +// p1: Int, +// p2: Int +// ) { +// TODO("Not yet implemented") +// } +// +// override fun dispose() { +// TODO("Not yet implemented") +// } +// +// override fun isScreencast(): Boolean { +// TODO("Not yet implemented") +// } +// +// } +// +// videoProcessor = object: VideoProcessor { +// override fun onCapturerStarted(p0: Boolean) { +// TODO("Not yet implemented") +// } +// +// override fun onCapturerStopped() { +// TODO("Not yet implemented") +// } +// +// override fun onFrameCaptured(p0: VideoFrame?) { +// TODO("Not yet implemented") +// } +// +// override fun setSink(p0: VideoSink?) { +// TODO("Not yet implemented") +// } +// +// } +// +// var timeStampNS: Long +// var n21Buffer: NV21Buffer +// var videoFrame: VideoFrame +// +// +// +// sendVideoCapturer(videoCapturer, context, videoCapturerObserver, localPeer, factory ) +// } +// +// +// fun sendVideoCapturer(capturer: VideoCapturer, context: Context, capturerObserver: CapturerObserver, localPeer: PeerConnection, factory: PeerConnectionFactory) { +// +// //localPeer = factory.createPeerConnection(config, peerConnObserver)!! +// +// +// val videoSource2 = factory.createVideoSource(true) +// val surfaceTexture = SurfaceTextureHelper.create("CaptureThread", rootEGL.eglBaseContext) +// //capturerObserver.onCapturerStarted(true) +// //capturer.initialize(surfaceTexture,context , capturerObserver) +// +// +// //videoSource2.setVideoProcessor(videoProcessor) +// //videoProcessor.onCapturerStarted(true) +// //capturer.startCapture(1080, 720, 30) +// +// var videoTrack = factory.createVideoTrack("0001", videoSource2) +// //videoSource = videoSource2 +// +// //localPeer.addTrack(videoTrack, listOf("track01")) +// +// +// +// //camera.initialize(surfaceTexture,context , capturerObserver) +// +// } +//} \ No newline at end of file diff --git a/app/src/main/java/com/example/neurology_project_android/sampledata/Consumer.java b/app/src/main/java/com/example/neurology_project_android/sampledata/Consumer.java new file mode 100644 index 0000000..f4f2e90 --- /dev/null +++ b/app/src/main/java/com/example/neurology_project_android/sampledata/Consumer.java @@ -0,0 +1,35 @@ +package com.example.neurology_project_android.sampledata; + +import org.webrtc.MediaStreamTrack; +import org.webrtc.RtpReceiver; + +import java.util.Objects; + +class Consumer { + String id; + String mid; + String peerId; + Engine.MediaKind kind; + + MediaStreamTrack track; + Consumer(String id, String mid){ + id = id; + mid = mid; + } + + public void setTrack(MediaStreamTrack track) { + this.track = track; + + if (Objects.equals(track.kind(), "audio")){ + kind = Engine.MediaKind.Audio; + } else { + kind = Engine.MediaKind.Video; + } + } + + + + public void setPeerId(String peerId) { + this.peerId = peerId; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/neurology_project_android/sampledata/Device.java b/app/src/main/java/com/example/neurology_project_android/sampledata/Device.java new file mode 100644 index 0000000..725206a --- /dev/null +++ b/app/src/main/java/com/example/neurology_project_android/sampledata/Device.java @@ -0,0 +1,479 @@ +package com.example.neurology_project_android.sampledata; + +import android.content.Context; +import android.util.Log; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; + +import org.webrtc.AudioSource; +import org.webrtc.AudioTrack; +import org.webrtc.CandidatePairChangeEvent; +import org.webrtc.DataChannel; +import org.webrtc.DefaultVideoDecoderFactory; +import org.webrtc.DefaultVideoEncoderFactory; +import org.webrtc.EglBase; +import org.webrtc.HardwareVideoDecoderFactory; +import org.webrtc.HardwareVideoEncoderFactory; +import org.webrtc.IceCandidate; +import org.webrtc.IceCandidateErrorEvent; +import org.webrtc.MediaConstraints; +import org.webrtc.MediaStream; +import org.webrtc.PeerConnection; +import org.webrtc.PeerConnectionFactory; +import org.webrtc.RtpReceiver; +import org.webrtc.SdpObserver; +import org.webrtc.SessionDescription; +import org.webrtc.SoftwareVideoDecoderFactory; +import org.webrtc.SoftwareVideoEncoderFactory; +import org.webrtc.VideoDecoderFactory; +import org.webrtc.VideoEncoderFactory; +import org.webrtc.VideoSource; +import org.webrtc.VideoTrack; +import org.webrtc.audio.AudioDeviceModule; +import org.webrtc.audio.JavaAudioDeviceModule; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.Callable; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; + +import one.dugon.mediasoup_android_sdk.sdp.Parser; +import one.dugon.mediasoup_android_sdk.sdp.Utils; + + +public class Device { + + private static final String TAG = "Dugon"; + + public static final String AUDIO_TRACK_ID = "ARDAMSa0"; + public static final String VIDEO_TRACK_ID = "ARDAMSv0"; + + public static final ExecutorService executor = Executors.newSingleThreadExecutor(); + + private static Context appContext; + + private static EglBase rootEglBase; + + private static PeerConnectionFactory factory; + + public static void initialize(Context context) { + appContext = context; + + rootEglBase = EglBase.create(); + + // TODO: 2024/9/30 log level + executor.execute(() -> { + +// Log.d(TAG, "Initialize WebRTC. Field trials: " + fieldTrials); + PeerConnectionFactory.initialize( + PeerConnectionFactory.InitializationOptions.builder(appContext) +// .setFieldTrials(fieldTrials) + .setEnableInternalTracer(false) + .createInitializationOptions()); + + + // PeerConnectionFactory + final AudioDeviceModule adm = createJavaAudioDevice(); + + // Create peer connection factory. + PeerConnectionFactory.Options options = new PeerConnectionFactory.Options(); + options.disableNetworkMonitor = false; +// final boolean enableH264HighProfile = +// VIDEO_CODEC_H264_HIGH.equals(peerConnectionParameters.videoCodec); + final VideoEncoderFactory encoderFactory; + final VideoDecoderFactory decoderFactory; + + +// encoderFactory = new DefaultVideoEncoderFactory( +// rootEglBase.getEglBaseContext(), true /* enableIntelVp8Encoder */, enableH264HighProfile); +// decoderFactory = new DefaultVideoDecoderFactory(rootEglBase.getEglBaseContext()); + + encoderFactory = new DefaultVideoEncoderFactory(rootEglBase.getEglBaseContext(),true, false); + decoderFactory = new DefaultVideoDecoderFactory(rootEglBase.getEglBaseContext()); + + // Disable encryption for loopback calls. +// if (peerConnectionParameters.loopback) { +// options.disableEncryption = true; +// } + + factory = PeerConnectionFactory.builder() + .setOptions(options) + //.setAudioDeviceModule(adm) + .setVideoEncoderFactory(encoderFactory) + .setVideoDecoderFactory(decoderFactory) + .createPeerConnectionFactory(); + Log.d(TAG, "Peer connection factory created."); + adm.release(); + }); + } + + public static JsonObject rtpCapabilities; + + public static JsonObject sctpCapabilities; + + public static JsonObject extendedRtpCapabilities; + + public static JsonObject getRtpCapabilities() { + CompletableFuture futureDesc = new CompletableFuture<>(); + + Callable task = () -> { + Log.d(TAG, "getRtpCapabilities"); + + List iceServers = new ArrayList<>(); + + PeerConnection.RTCConfiguration rtcConfig = + new PeerConnection.RTCConfiguration(iceServers); + // TCP candidates are only useful when connecting to a server that supports + // ICE-TCP. + rtcConfig.tcpCandidatePolicy = PeerConnection.TcpCandidatePolicy.ENABLED; + rtcConfig.bundlePolicy = PeerConnection.BundlePolicy.MAXBUNDLE; + //rtcConfig.rtcpMuxPolicy = PeerConnection.RtcpMuxPolicy.REQUIRE; + //rtcConfig.continualGatheringPolicy = PeerConnection.ContinualGatheringPolicy.GATHER_CONTINUALLY; + // Use ECDSA encryption. + //rtcConfig.keyType = PeerConnection.KeyType.ECDSA; + rtcConfig.sdpSemantics = PeerConnection.SdpSemantics.UNIFIED_PLAN; + + assert factory != null; + PCObserverForRtpCaps pcObserver = new PCObserverForRtpCaps(); + PeerConnection peerConnection = factory.createPeerConnection(rtcConfig, pcObserver); + + MediaConstraints sdpMediaConstraints = new MediaConstraints(); + + sdpMediaConstraints.mandatory.add( + new MediaConstraints.KeyValuePair("OfferToReceiveAudio", "true")); + sdpMediaConstraints.mandatory.add( + new MediaConstraints.KeyValuePair("OfferToReceiveVideo", "true")); + + SDPObserverForRtpCaps sdpObserver = new SDPObserverForRtpCaps() { + @Override + public void onCreateSuccess(SessionDescription desc) { +// super.onCreateSuccess(desc); + Log.d("w", "onCreateSuccess"); + futureDesc.complete(desc); + + } + + @Override + public void onCreateFailure(String error) { +// super.onCreateFailure(error); + futureDesc.completeExceptionally(new Exception(error)); + + } + + }; + assert peerConnection != null; + peerConnection.createOffer(sdpObserver, sdpMediaConstraints); + + return 1; + }; + + executor.submit(task); + + try { + SessionDescription sdp = futureDesc.get(); + + JsonObject sdpSession = Parser.parse(sdp.description); +// var sdpStr = Writer.write(sdpSession); + JsonObject rtpCapabilities = Utils.extractRtpCapabilities(sdpSession); + Log.d("W", rtpCapabilities.toString()); +// Log.d("W",sdp.description); + return rtpCapabilities; + + } catch (Exception e) { + e.printStackTrace(); + } + return null; + } + + public static LocalVideoSource createVideoSource() { + + Callable task = () -> { + boolean isScreencast = false; + + VideoSource videoSource = factory.createVideoSource(isScreencast); + + VideoTrack localVideoTrack = factory.createVideoTrack(VIDEO_TRACK_ID, videoSource); + localVideoTrack.setEnabled(true); + + return new LocalVideoSource(appContext, rootEglBase, videoSource, localVideoTrack); + }; + + Future future = executor.submit(task); + + try { + return future.get(); + } catch (Exception e) { +// e.printStackTrace(); + } + return null; + } + + public static LocalAudioSource createAudioSource() { + Callable task = () -> { + + MediaConstraints audioConstraints = new MediaConstraints();; + + AudioSource audioSource = factory.createAudioSource(audioConstraints); + AudioTrack localAudioTrack = factory.createAudioTrack(AUDIO_TRACK_ID, audioSource); + + localAudioTrack.setEnabled(true); + + return new LocalAudioSource(audioSource, localAudioTrack); + }; + + Future future = executor.submit(task); + + try { + return future.get(); + } catch (Exception e) { +// e.printStackTrace(); + } + return null; + } + + static AudioDeviceModule createJavaAudioDevice() { + // Enable/disable OpenSL ES playback. +// if (!peerConnectionParameters.useOpenSLES) { +// Log.w(TAG, "External OpenSLES ADM not implemented yet."); +// // TODO(magjed): Add support for external OpenSLES ADM. +// } + + // Set audio record error callbacks. + JavaAudioDeviceModule.AudioRecordErrorCallback audioRecordErrorCallback = new JavaAudioDeviceModule.AudioRecordErrorCallback() { + @Override + public void onWebRtcAudioRecordInitError(String errorMessage) { + Log.e(TAG, "onWebRtcAudioRecordInitError: " + errorMessage); +// reportError(errorMessage); + } + + @Override + public void onWebRtcAudioRecordStartError( + JavaAudioDeviceModule.AudioRecordStartErrorCode errorCode, String errorMessage) { + Log.e(TAG, "onWebRtcAudioRecordStartError: " + errorCode + ". " + errorMessage); +// reportError(errorMessage); + } + + @Override + public void onWebRtcAudioRecordError(String errorMessage) { + Log.e(TAG, "onWebRtcAudioRecordError: " + errorMessage); +// reportError(errorMessage); + } + }; + + JavaAudioDeviceModule.AudioTrackErrorCallback audioTrackErrorCallback = new JavaAudioDeviceModule.AudioTrackErrorCallback() { + @Override + public void onWebRtcAudioTrackInitError(String errorMessage) { + Log.e(TAG, "onWebRtcAudioTrackInitError: " + errorMessage); +// reportError(errorMessage); + } + + @Override + public void onWebRtcAudioTrackStartError( + JavaAudioDeviceModule.AudioTrackStartErrorCode errorCode, String errorMessage) { + Log.e(TAG, "onWebRtcAudioTrackStartError: " + errorCode + ". " + errorMessage); +// reportError(errorMessage); + } + + @Override + public void onWebRtcAudioTrackError(String errorMessage) { + Log.e(TAG, "onWebRtcAudioTrackError: " + errorMessage); +// reportError(errorMessage); + } + }; + + // Set audio record state callbacks. + JavaAudioDeviceModule.AudioRecordStateCallback audioRecordStateCallback = new JavaAudioDeviceModule.AudioRecordStateCallback() { + @Override + public void onWebRtcAudioRecordStart() { + Log.i(TAG, "Audio recording starts"); + } + + @Override + public void onWebRtcAudioRecordStop() { + Log.i(TAG, "Audio recording stops"); + } + }; + + // Set audio track state callbacks. + JavaAudioDeviceModule.AudioTrackStateCallback audioTrackStateCallback = new JavaAudioDeviceModule.AudioTrackStateCallback() { + @Override + public void onWebRtcAudioTrackStart() { + Log.i(TAG, "Audio playout starts"); + } + + @Override + public void onWebRtcAudioTrackStop() { + Log.i(TAG, "Audio playout stops"); + } + }; + + return JavaAudioDeviceModule.builder(appContext) +// .setSamplesReadyCallback(saveRecordedAudioToFile) +// .setUseHardwareAcousticEchoCanceler(!peerConnectionParameters.disableBuiltInAEC) +// .setUseHardwareNoiseSuppressor(!peerConnectionParameters.disableBuiltInNS) + .setAudioRecordErrorCallback(audioRecordErrorCallback) + .setAudioTrackErrorCallback(audioTrackErrorCallback) + .setAudioRecordStateCallback(audioRecordStateCallback) + .setAudioTrackStateCallback(audioTrackStateCallback) + .createAudioDeviceModule(); + } + + public static void initView(Player player) { + player.initEgl(rootEglBase.getEglBaseContext()); + } + + + static class PCObserverForRtpCaps implements PeerConnection.Observer { + @Override + public void onIceCandidate(final IceCandidate candidate) { + } + + @Override + public void onIceCandidateError(final IceCandidateErrorEvent event) { + } + + @Override + public void onIceCandidatesRemoved(final IceCandidate[] candidates) { + } + + @Override + public void onSignalingChange(PeerConnection.SignalingState newState) { + } + + @Override + public void onIceConnectionChange(final PeerConnection.IceConnectionState newState) { + } + + @Override + public void onConnectionChange(final PeerConnection.PeerConnectionState newState) { + } + + @Override + public void onIceGatheringChange(PeerConnection.IceGatheringState newState) { + } + + @Override + public void onIceConnectionReceivingChange(boolean receiving) { + } + + @Override + public void onSelectedCandidatePairChanged(CandidatePairChangeEvent event) { + } + + @Override + public void onAddStream(final MediaStream stream) { + } + + @Override + public void onRemoveStream(final MediaStream stream) { + } + + @Override + public void onDataChannel(final DataChannel dc) { + } + + @Override + public void onRenegotiationNeeded() { + } + + @Override + public void onAddTrack(final RtpReceiver receiver, final MediaStream[] mediaStreams) { + } + + @Override + public void onRemoveTrack(final RtpReceiver receiver) { + } + } + + static class SDPObserverForRtpCaps implements SdpObserver { + @Override + public void onCreateSuccess(final SessionDescription desc) { + } + + @Override + public void onCreateFailure(final String error) { + } + + @Override + public void onSetSuccess() { + } + + @Override + public void onSetFailure(final String error) { + } + } + + // for mediasoup + public static void load(JsonObject routerRtpCapabilities) { + Log.d(TAG, "getRtpCapabilities 1"); + + JsonObject numStreams = new JsonObject(); + numStreams.addProperty("OS", "1024"); + numStreams.addProperty("MIS", "1024"); + + sctpCapabilities = new JsonObject(); + sctpCapabilities.add("numStreams", numStreams); + + Log.d(TAG, "getRtpCapabilities 2"); + + JsonObject local = getRtpCapabilities(); + Log.d(TAG, "getRtpCapabilities ok"); + extendedRtpCapabilities = Utils.getExtendedRtpCapabilities(local, routerRtpCapabilities); + Log.d(TAG, extendedRtpCapabilities.toString()); + rtpCapabilities = Utils.getRecvRtpCapabilities(extendedRtpCapabilities); + } + + public static SendTransport createSendTransport( + String id, + JsonObject iceParameters, + JsonArray iceCandidates, + JsonObject dtlsParameters + ) { + JsonObject audioSendingRtpParameters = Utils.getSendingRtpParameters("audio", extendedRtpCapabilities); + JsonObject videoSendingRtpParameters = Utils.getSendingRtpParameters("video", extendedRtpCapabilities); + JsonObject sendingRtpParametersByKind = new JsonObject(); + sendingRtpParametersByKind.add("audio", audioSendingRtpParameters); + sendingRtpParametersByKind.add("video", videoSendingRtpParameters); + + + JsonObject audioSendingRemoteRtpParameters = Utils.getSendingRemoteRtpParameters("audio", extendedRtpCapabilities); + JsonObject videoSendingRemoteRtpParameters = Utils.getSendingRemoteRtpParameters("video", extendedRtpCapabilities); + JsonObject sendingRemoteRtpParametersByKind = new JsonObject(); + sendingRemoteRtpParametersByKind.add("audio", audioSendingRemoteRtpParameters); + sendingRemoteRtpParametersByKind.add("video", videoSendingRemoteRtpParameters); + + SendTransport t = new SendTransport(id, iceParameters, iceCandidates, dtlsParameters, sendingRtpParametersByKind, sendingRemoteRtpParametersByKind); + // + List iceServers = new ArrayList<>(); + + PeerConnection.RTCConfiguration rtcConfig = + new PeerConnection.RTCConfiguration(iceServers); + + PeerConnection peerConnection = factory.createPeerConnection(rtcConfig, t.transport); + t.start(peerConnection); + return t; + } + + public static RecvTransport createRecvTransport( + String id, + JsonObject iceParameters, + JsonArray iceCandidates, + JsonObject dtlsParameters + ) { + RecvTransport t = new RecvTransport(id,iceParameters,iceCandidates,dtlsParameters); + // + List iceServers = new ArrayList<>(); + + PeerConnection.RTCConfiguration rtcConfig = + new PeerConnection.RTCConfiguration(iceServers); + rtcConfig.bundlePolicy = PeerConnection.BundlePolicy.MAXBUNDLE; + + PeerConnection peerConnection = factory.createPeerConnection(rtcConfig, t.transport); + t.transport.start(peerConnection); + return t; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/neurology_project_android/sampledata/Engine.java b/app/src/main/java/com/example/neurology_project_android/sampledata/Engine.java new file mode 100644 index 0000000..f749a53 --- /dev/null +++ b/app/src/main/java/com/example/neurology_project_android/sampledata/Engine.java @@ -0,0 +1,314 @@ +package com.example.neurology_project_android.sampledata; + +import android.content.Context; +import android.util.Log; + +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.reflect.TypeToken; + +import org.json.JSONException; +import org.json.JSONObject; +import org.webrtc.MediaStreamTrack; +import org.webrtc.RtpReceiver; +import org.webrtc.VideoTrack; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.function.BiConsumer; +import java.util.function.Consumer; +import java.util.function.Function; + +import one.dugon.mediasoup_android_sdk.protoo.ProtooEventListener; +import one.dugon.mediasoup_android_sdk.protoo.ProtooSocket; + + +public class Engine { + + public enum PeerState { + Join, + Leave, + } + + public enum MediaKind { + Audio, + Video, + } + + public interface Listener { + void onPeer(String peerId, PeerState state); + void onMedia(String peerId, String consumerId , MediaKind kind, boolean available); + } + + private static final String TAG = "Engine"; + + private Listener listener; + + private ProtooSocket protoo; + private SendTransport sendTransport; + private RecvTransport recvTransport; + + private LocalAudioSource localAudioSource; + private LocalVideoSource localVideoSource; + + private static final ExecutorService executor = Executors.newSingleThreadExecutor(); + + public Consumer onTrack = null; + + List peerList; + + HashMap consumerHashMap; + HashMap tracks; + + + public Engine(Context context){ + //protoo = new ProtooSocket(); + consumerHashMap = new HashMap<>(); + tracks = new HashMap<>(); + Device.initialize(context); + } + + public void setListener(Listener listener){ + this.listener = listener; + } + + public void connect(String signalServer, String roomId, String peerId){ + + protoo.setEventListener(new ProtooEventListener() { + @Override + public void onConnect() { + Log.d(TAG, "onConnect"); + + executor.execute(()->{ + getRtpCaps(); + createWebRTCTransport(false); + createWebRTCTransport(true); + //join(); + }); + } + + @Override + public void onDisconnect() { + + } + + @Override + public void onRequest(JsonObject requestData) { + Log.d(TAG,"onRequest:"); + String requestMethod = requestData.get("method").getAsString(); + if (Objects.equals(requestMethod, "newConsumer")) { + + Log.d(TAG,"newConsumer"); + int id = requestData.get("id").getAsInt(); + JsonObject data = requestData.get("data").getAsJsonObject(); + String peerId = data.get("peerId").getAsString(); + + String kind = data.get("kind").getAsString(); + String consumerId = data.get("id").getAsString(); + JsonObject rtpParameters= data.get("rtpParameters").getAsJsonObject(); + + + Device.executor.execute(()->{ + com.example.neurology_project_android.sampledata.Consumer consumer = recvTransport.receive(consumerId,kind,rtpParameters); + consumer.setPeerId(peerId); + + MediaStreamTrack track = tracks.get(consumerId); + + MediaKind k; + if (Objects.equals(track.kind(), "audio")){ + k = Engine.MediaKind.Audio; + } else { + k = Engine.MediaKind.Video; + } + + consumer.setTrack(track); + consumerHashMap.put(consumerId, consumer); + + listener.onMedia(peerId, consumerId, k, true); +// if(kind.equals("video")){ +// Log.d(TAG,"video !" + transceiver.getMid()); +// var track = transceiver.getReceiver().track(); +// var videotrack = (VideoTrack)track; +// videotrack.setEnabled(true); +// +// myVideoTrack = videotrack; +// addRemoteVideoRenderer(videotrack); +// } + + protoo.response(id); + }); + + + } + } + + @Override + public void onNotification(String method, JsonObject data) { + if(Objects.equals(method, "newPeer")){ + Gson gson = new Gson(); + Peer peer = gson.fromJson(data, new TypeToken(){}.getType()); + peerList.add(peer); + listener.onPeer(peer.id, PeerState.Join); + } else if(Objects.equals(method, "peerClosed")){ + String peerId = data.get("peerId").getAsString(); + peerList.removeIf(peer -> peer.id.equals(peerId)); + listener.onPeer(peerId, PeerState.Leave); + } + } + + @Override + public void onError() { + + } + }); + + protoo.connect(signalServer, Map.of("roomId", roomId, "peerId", peerId)); + } + + private void getRtpCaps(){ + JsonObject response = protoo.requestSync("getRouterRtpCapabilities"); + Device.load(response); + } + + private void createWebRTCTransport(boolean isSender){ + JsonObject createData = new JsonObject(); + createData.addProperty("consuming", !isSender); + createData.addProperty("forceTcp", false); + createData.addProperty("producing", isSender); + + JsonObject response = protoo.requestSync("createWebRtcTransport", createData); + + String id = response.get("id").getAsString(); + JsonObject iceParameters = response.getAsJsonObject("iceParameters"); + JsonArray iceCandidates = response.getAsJsonArray("iceCandidates"); + JsonObject dtlsParameters = response.getAsJsonObject("dtlsParameters"); + + if (isSender){ + sendTransport = Device.createSendTransport(id, iceParameters, iceCandidates, dtlsParameters); + +// sendTransport.onConnect = (JSONObject dtls)->{ +// Log.d(TAG,"dtls:"+dtls.toString()); +// JsonObject connectData = new JsonObject(); +// connectData.addProperty("transportId", id); +// //connectData.add("dtlsParameters",dtls); +//// var r4 = socket.request("connectWebRtcTransport", connectData); +// //JsonObject connectResponse = protoo.requestSync("connectWebRtcTransport", connectData); +// Log.d(TAG,"dtls: ok"); +// }; + +// sendTransport.onProduce = (JsonObject pData)->{ +// Log.d(TAG, "onProduce:"); +// pData.addProperty("transportId",id); +// JsonObject produceResponse = protoo.requestSync("produce", pData); +// return produceResponse.get("id").getAsString(); +// }; + + }else{ + recvTransport = Device.createRecvTransport(id,iceParameters,iceCandidates,dtlsParameters); + recvTransport.onTrack = (MediaStreamTrack track)->{ +// String kind = track.kind(); +// if(kind.equals("video")){ +// var videotrack = (VideoTrack)track; +// videotrack.setEnabled(true); +// +//// myVideoTrack = videotrack; +// addRemoteVideoRenderer(videotrack); +// } + tracks.put(track.id(),track); + }; + + + recvTransport.onConnect = (JSONObject dtls)->{ + Log.d(TAG,"recv dtls:"+dtls.toString()); + JsonObject connectData = new JsonObject(); + connectData.addProperty("transportId", id); + //connectData.add("dtlsParameters",dtls); +// var r4 = socket.request("connectWebRtcTransport", connectData); + JsonObject connectResponse = protoo.requestSync("connectWebRtcTransport", connectData); + Log.d(TAG,"dtls: ok"); + }; + +// recvTransport.onTrack = (String trackId)-> { +// if(onTrack != null){ +// onTrack.accept(trackId); +// } +// }; +// +// recvTransport.onTrack = (RtpReceiver receiver) ->{ +// Log.d(TAG,"Fuck " + receiver.track().id()); +// +// receivers.put(receiver.track().id(), receiver); +// }; + + } + + } + + private void join(){ + JsonObject joinData = new JsonObject(); + JsonObject rtpCapabilitiesJson = Device.rtpCapabilities; + JsonObject sctpCapabilitiesJson = Device.sctpCapabilities; + + JsonObject device = new JsonObject(); + device.addProperty("flag", "chrome"); + device.addProperty("name", "Chrome"); + device.addProperty("version", "129.0.0.0"); + + joinData.add("device", device); + joinData.add("rtpCapabilities", rtpCapabilitiesJson); + joinData.add("sctpCapabilities", sctpCapabilitiesJson); + joinData.addProperty("displayName", "gg"); + + JsonObject response = protoo.requestSync("join", joinData); + // TODO: 2025/3/16 handle response + JsonArray peers = response.get("peers").getAsJsonArray(); +// for (JsonElement p : peers) { +// Log.d(TAG,"peer"); +// +// } + // TODO: 2025/8/14 reuse gson + Gson gson = new Gson(); + peerList = gson.fromJson(peers, new TypeToken>(){}.getType()); + for(Peer p: peerList ){ + Log.d(TAG, "peer join " + p.id); + listener.onPeer(p.id, PeerState.Join); + } + } + + public void enableCam() throws JSONException { + localVideoSource = Device.createVideoSource(); +// sendTransport.abc(); + sendTransport.send(localVideoSource); + } + + public void enableMic() throws JSONException { + localAudioSource = Device.createAudioSource(); + sendTransport.send(localAudioSource); + } + + public void previewCam(Player player){ + player.play(localVideoSource); + } + + public void initView(Player player){ + Device.initView(player); + } + + public void play(Player player, String consumerId){ + com.example.neurology_project_android.sampledata.Consumer consumer = consumerHashMap.get(consumerId); + + if(consumer.kind == MediaKind.Video){ + Log.d(TAG,"Play " + consumerId); + + VideoTrack videoTrack = (VideoTrack) consumer.track; + player.play(videoTrack); + } + + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/neurology_project_android/sampledata/LocalAudioSource.java b/app/src/main/java/com/example/neurology_project_android/sampledata/LocalAudioSource.java new file mode 100644 index 0000000..57554df --- /dev/null +++ b/app/src/main/java/com/example/neurology_project_android/sampledata/LocalAudioSource.java @@ -0,0 +1,25 @@ +package com.example.neurology_project_android.sampledata; + +import org.webrtc.AudioSource; +import org.webrtc.AudioTrack; +import org.webrtc.MediaStreamTrack; + +public class LocalAudioSource extends LocalSource{ + private AudioTrack track; + private AudioSource source; + + public LocalAudioSource(AudioSource audioSource, AudioTrack audioTrack) { + source = audioSource; + track = audioTrack; + } + + @Override + public MediaStreamTrack getTrack() { + return track; + } + + @Override + public String getKind() { + return "audio"; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/neurology_project_android/sampledata/LocalSource.java b/app/src/main/java/com/example/neurology_project_android/sampledata/LocalSource.java new file mode 100644 index 0000000..eae742b --- /dev/null +++ b/app/src/main/java/com/example/neurology_project_android/sampledata/LocalSource.java @@ -0,0 +1,15 @@ +package com.example.neurology_project_android.sampledata; // <-- Must be your package! + + +import org.webrtc.MediaStreamTrack; +// FIX 1: Make the class public so your RoomClient can inherit/reference it. +public abstract class LocalSource { + + // FIX 2 (Crucial): The abstract methods must be public + // if you want external classes to rely on them. + public abstract MediaStreamTrack getTrack(); + public abstract String getKind(); + + + // ... other abstract methods ... +} \ No newline at end of file diff --git a/app/src/main/java/com/example/neurology_project_android/sampledata/LocalVideoSource.java b/app/src/main/java/com/example/neurology_project_android/sampledata/LocalVideoSource.java new file mode 100644 index 0000000..b59b498 --- /dev/null +++ b/app/src/main/java/com/example/neurology_project_android/sampledata/LocalVideoSource.java @@ -0,0 +1,95 @@ +package com.example.neurology_project_android.sampledata; + +import android.content.Context; +import android.hardware.camera2.CameraAccessException; +import android.hardware.camera2.CameraManager; + +import androidx.annotation.Nullable; + +import org.webrtc.Camera2Capturer; +import org.webrtc.Camera2Enumerator; +import org.webrtc.CameraEnumerator; +import org.webrtc.EglBase; +import org.webrtc.Logging; +import org.webrtc.MediaStreamTrack; +import org.webrtc.SurfaceTextureHelper; +import org.webrtc.VideoCapturer; +import org.webrtc.VideoSource; +import org.webrtc.VideoTrack; + +public class LocalVideoSource extends LocalSource { + + private static final String TAG = "LocalVideoSource"; + private final Context appContext; + public VideoTrack track; + private final VideoCapturer capturer; + private final VideoSource source; + private final SurfaceTextureHelper surfaceTextureHelper; + + public LocalVideoSource(Context context, EglBase rootEglBase, VideoSource videoSource, VideoTrack videoTrack) throws CameraAccessException { + appContext = context; + source = videoSource; + track = videoTrack; + CameraManager cameraManager = (CameraManager) context.getSystemService(Context.CAMERA_SERVICE); + //String lastCameraId = cameraManager.getCameraIdList()[cameraManager.getCameraIdList().length-1]; + String lastCameraId = cameraManager.getCameraIdList()[cameraManager.getCameraIdList().length - 1]; + Camera2Capturer cameraCapturer = new Camera2Capturer(context, lastCameraId, null); + + CameraEnumerator enumerator = new Camera2Enumerator(appContext); + capturer = createCameraCapturer(enumerator); +// capturer = createCameraCapturer(new Camera2Enumerator(appContext)); + surfaceTextureHelper = + SurfaceTextureHelper.create("CaptureThread", rootEglBase.getEglBaseContext()); +// capturer.initialize(surfaceTextureHelper, appContext, source.getCapturerObserver()); +// capturer.startCapture(1080, 1920, 30); + cameraCapturer.initialize(surfaceTextureHelper, appContext, source.getCapturerObserver()); + cameraCapturer.startCapture(1920, 1080, 30); + } + + @Override + public MediaStreamTrack getTrack() { + return track; + } + + @Override + public String getKind() { + return "video"; + } + + + // public void play(Player player){ +// track.addSink(player); +// } + + private @Nullable VideoCapturer createCameraCapturer(CameraEnumerator enumerator) { + final String[] deviceNames = enumerator.getDeviceNames(); + + // First, try to find front facing camera + Logging.d(TAG, "Looking for front facing cameras."); + for (String deviceName : deviceNames) { +// if (enumerator.isFrontFacing(deviceName)) { + Logging.d(TAG, "Creating camera capturer."); + VideoCapturer videoCapturer = enumerator.createCapturer(deviceName, null); + + if (videoCapturer != null) { + return videoCapturer; + } +// } + } + + // Front facing camera not found, try something else + Logging.d(TAG, "Looking for other cameras."); + for (String deviceName : deviceNames) { + if (!enumerator.isFrontFacing(deviceName)) { + Logging.d(TAG, "Creating other camera capturer."); + VideoCapturer videoCapturer = enumerator.createCapturer(deviceName, null); + + if (videoCapturer != null) { + return videoCapturer; + } + } + } + + return null; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/neurology_project_android/sampledata/Peer.java b/app/src/main/java/com/example/neurology_project_android/sampledata/Peer.java new file mode 100644 index 0000000..a74c193 --- /dev/null +++ b/app/src/main/java/com/example/neurology_project_android/sampledata/Peer.java @@ -0,0 +1,32 @@ +package com.example.neurology_project_android.sampledata; + +import com.google.gson.JsonObject; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +public class Peer { + + public String id; + public String displayName; + + private List consumers; + + Peer(String id, String displayName){ + consumers = new ArrayList<>(); + } + + void addConsumer(String id, JsonObject rtpParameters){ + String mid = rtpParameters.get("mid").getAsString(); + Consumer consumer = new Consumer(id, mid); + consumers.add(consumer); + } + + Consumer getConsumer(String consumerId){ + return consumers.stream() + .filter(c -> Objects.equals(c.id, consumerId)) + .findFirst() + .orElse(null); // 没找到返回null + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/neurology_project_android/sampledata/Player.java b/app/src/main/java/com/example/neurology_project_android/sampledata/Player.java new file mode 100644 index 0000000..5051976 --- /dev/null +++ b/app/src/main/java/com/example/neurology_project_android/sampledata/Player.java @@ -0,0 +1,49 @@ +package com.example.neurology_project_android.sampledata; + +import android.content.Context; +import android.util.AttributeSet; +import android.widget.FrameLayout; + +import org.webrtc.EglBase; +import org.webrtc.MediaStreamTrack; +import org.webrtc.SurfaceViewRenderer; +import org.webrtc.VideoTrack; + +public class Player extends FrameLayout { + + private SurfaceViewRenderer renderer; + + public Player(Context context) { + super(context); + init(context); + } + + public Player(Context context, AttributeSet attrs) { + super(context, attrs); + init(context); + } + + private void init(Context context) { + renderer = new SurfaceViewRenderer(context); + addView(renderer, new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)); + } + + public void initEgl(EglBase.Context context){ + renderer.init(context, null); + } + + public void setLayoutParams(LayoutParams params){ + renderer.setLayoutParams(params); + } + + public void play(LocalVideoSource source){ + source.track.addSink(renderer); + } + + void play(VideoTrack track){ + track.addSink(renderer); + } + + + +} \ No newline at end of file diff --git a/app/src/main/java/com/example/neurology_project_android/sampledata/RecvTransport.java b/app/src/main/java/com/example/neurology_project_android/sampledata/RecvTransport.java new file mode 100644 index 0000000..af23fef --- /dev/null +++ b/app/src/main/java/com/example/neurology_project_android/sampledata/RecvTransport.java @@ -0,0 +1,184 @@ +package com.example.neurology_project_android.sampledata; + +import android.util.Log; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; + +import org.json.JSONException; +import org.json.JSONObject; +import org.webrtc.MediaConstraints; +import org.webrtc.MediaStreamTrack; +import org.webrtc.RtpReceiver; +import org.webrtc.RtpTransceiver; +import org.webrtc.SessionDescription; + +import java.util.concurrent.Callable; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; +import java.util.function.Consumer; + +import one.dugon.mediasoup_android_sdk.sdp.Parser; + +public class RecvTransport{ + private static final String TAG = "RecvTransport"; + + public Consumer onConnect; + public Consumer onTrack = null; + + Transport transport; + public RecvTransport(String id, JsonObject iceParameters, JsonArray iceCandidates, JsonObject dtlsParameters) { + transport = new Transport(id, iceParameters, iceCandidates, dtlsParameters); + transport.onConnect = (JSONObject dtls)->{ + Log.d(TAG, "onConnect:"); + onConnect.accept(dtls); + }; + +// transport.onTrack = (String trackId)->{ +// onTrack.accept(trackId); +// }; + transport.onTrack = (MediaStreamTrack track)->{ + onTrack.accept(track); + }; + + } + + public com.example.neurology_project_android.sampledata.Consumer receive(String id, String kind, JsonObject rtpParameters){ + + Callable task = () -> receiveInternal(id, kind, rtpParameters); + + Future future = transport.executor.submit(task); + + try { + return future.get(); + } catch (Exception e) { +// e.printStackTrace(); + } + return null; + } + + + private com.example.neurology_project_android.sampledata.Consumer receiveInternal(String id, String kind, JsonObject rtpParameters){ + // TODO: 2025/3/2 maybe get mid from mapMidTransceiver + // https://github.com/versatica/libmediasoupclient/blob/v3/src/Handler.cpp#L652C35-L652C52 + String localId = rtpParameters.get("mid").getAsString(); + String cname = rtpParameters.getAsJsonObject("rtcp").get("cname").getAsString(); + + transport.remoteSdp.receive(localId, kind, rtpParameters,cname,id); +// + String offer = transport.remoteSdp.getSdp(); + + Log.i(TAG, offer); +// + CompletableFuture futureSetRemote = new CompletableFuture<>(); +// + transport.pc.setRemoteDescription(new Device.SDPObserverForRtpCaps(){ + @Override + public void onSetSuccess() { + futureSetRemote.complete(null); + } + + @Override + public void onSetFailure(String error) { + futureSetRemote.completeExceptionally(new Exception(error)); + } + }, new SessionDescription(SessionDescription.Type.OFFER,offer)); + + try { + futureSetRemote.get(); + Log.i(TAG,"setRemoteDescription ok"); + + } catch (ExecutionException e) { + throw new RuntimeException(e); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + + + MediaConstraints sdpMediaConstraints = new MediaConstraints(); + + CompletableFuture futureDesc = new CompletableFuture<>(); + + transport.pc.createAnswer(new Device.SDPObserverForRtpCaps() { + @Override + public void onCreateSuccess(SessionDescription desc) { + Log.i(TAG,"createAnswer ok"); + + futureDesc.complete(desc); + } + + @Override + public void onCreateFailure(String error) { + futureDesc.completeExceptionally(new Exception(error)); + } + }, sdpMediaConstraints); + + try { + SessionDescription answer = futureDesc.get(); + Log.d(TAG, answer.description); + Log.d(TAG, localId); + + + JsonObject localSdpObj = Parser.parse(answer.description); + + // TODO: 2025/3/3 + // May need to modify codec parameters in the answer based on codec + // parameters in the offer. + + // + // var media = localSdpObj.getAsJsonArray("media"); + // JsonObject m_select; + // for (JsonElement m : media) { + // JsonObject m_n = m.getAsJsonObject(); + // if (Objects.equals(m_n.get("mid").getAsString(), localId)){ + // m_select = m_n; + // Log.i(TAG, "selected:"+localId); + // } + // } + // https://github.com/versatica/libmediasoupclient/blob/v3/src/Handler.cpp#L679 + //Sdp::Utils::applyCodecParameters(*rtpParameters, answerMediaObject); + + if(!transport.ready){ + transport.SetupTransport("", localSdpObj); + } + + Log.d(TAG, "ready!!"); + + CompletableFuture futureDesc2 = new CompletableFuture<>(); + + transport.pc.setLocalDescription(new Device.SDPObserverForRtpCaps() { + @Override + public void onSetSuccess() { + Log.i(TAG,"setLocalDescription ok"); + futureDesc2.complete(null); + } + + @Override + public void onSetFailure(String error) { + Log.i(TAG,"setLocalDescription "+error); + futureDesc2.completeExceptionally(new Exception(error)); + } + }, answer); + + futureDesc2.get(); + + //https://bugs.chromium.org/p/webrtc/issues/detail?id=10788&q=getTransceivers()&colspec=ID%20Pri%20Stars%20M%20Component%20Status%20Owner%20Summary%20Modified +// var transceivers = pc.getTransceivers(); +// RtpTransceiver rtpTransceiver = null; +// for (var t : transceivers){ +// if(localId.equals(t.getMid())){ +// rtpTransceiver = t; +// } +// } +// +// return rtpTransceiver; + com.example.neurology_project_android.sampledata.Consumer consumer = new com.example.neurology_project_android.sampledata.Consumer(id,localId); + return consumer; + } catch (ExecutionException | JSONException e) { + throw new RuntimeException(e); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/neurology_project_android/sampledata/SendTransport.java b/app/src/main/java/com/example/neurology_project_android/sampledata/SendTransport.java new file mode 100644 index 0000000..6679d10 --- /dev/null +++ b/app/src/main/java/com/example/neurology_project_android/sampledata/SendTransport.java @@ -0,0 +1,178 @@ +package com.example.neurology_project_android.sampledata; + +import android.util.Log; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; + +import org.json.JSONException; +import org.json.JSONObject; +import org.webrtc.MediaConstraints; +import org.webrtc.MediaStreamTrack; +import org.webrtc.PeerConnection; +import org.webrtc.RtpParameters; +import org.webrtc.RtpTransceiver; +import org.webrtc.SessionDescription; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.function.Consumer; +import java.util.function.Function; + +import one.dugon.mediasoup_android_sdk.sdp.Parser; +import one.dugon.mediasoup_android_sdk.sdp.RemoteSdp; +import one.dugon.mediasoup_android_sdk.sdp.Utils; + + +public class SendTransport { + + private static final String TAG = "SendTransport"; + + public Function onProduce; + + public JsonObject sendingRtpParametersByKind; + public JsonObject sendingRemoteRtpParametersByKind; + + public Consumer onConnect; + + public Transport transport; + public JsonObject produceData; + + public SendTransport(String id, + JsonObject iceParameters, + JsonArray iceCandidates, + JsonObject dtlsParameters, + JsonObject sendingRtpParametersByKind, + JsonObject sendingRemoteRtpParametersByKind) { + transport = new Transport(id,iceParameters,iceCandidates,dtlsParameters); + transport.onConnect = (JSONObject dtls)->{ + Log.d(TAG, "onConnect:"); + //Log.d(TAG, "dtls is" + dtls.toString()); + //onConnect.accept(dtls); + }; + + this.sendingRtpParametersByKind = sendingRtpParametersByKind.deepCopy(); + this.sendingRemoteRtpParametersByKind = sendingRemoteRtpParametersByKind.deepCopy(); + } + + public void start(PeerConnection peerConnection) { + transport.pc = peerConnection; + } + + + + // + public void send(LocalSource source) throws JSONException { + + MediaStreamTrack track = source.getTrack(); + List encodings = new ArrayList<>(); + + JsonObject sendingRtpParameters = sendingRtpParametersByKind.getAsJsonObject(track.kind()).deepCopy(); + JsonObject sendingRemoteRtpParameters = sendingRemoteRtpParametersByKind.getAsJsonObject(track.kind()).deepCopy(); +// reduceCodecs + RemoteSdp.MediaSectionIdx mediaSectionIdx = transport.remoteSdp.getNextMediaSectionIdx(); + RtpTransceiver transceiver = transport.pc.addTransceiver(track); + + MediaConstraints sdpMediaConstraints = new MediaConstraints(); + + CompletableFuture futureDesc = new CompletableFuture<>(); + + transport.pc.createOffer(new Device.SDPObserverForRtpCaps() { + @Override + public void onCreateSuccess(SessionDescription desc) { + futureDesc.complete(desc); + } + + @Override + public void onCreateFailure(String error) { + futureDesc.completeExceptionally(new Exception(error)); + } + }, sdpMediaConstraints); + + try { + SessionDescription sdp = futureDesc.get(); + Log.d(TAG, sdp.description); + + CompletableFuture futureDesc2 = new CompletableFuture<>(); + + transport.pc.setLocalDescription(new Device.SDPObserverForRtpCaps() { + @Override + public void onSetSuccess() { + futureDesc2.complete(null); + } + + @Override + public void onSetFailure(String error) { + futureDesc2.completeExceptionally(new Exception(error)); + } + }, sdp); + + futureDesc2.get(); + String localId = transceiver.getMid(); + Log.d(TAG, "localId:" + localId); + + sendingRtpParameters.addProperty("mid", localId); + } catch (ExecutionException e) { + throw new RuntimeException(e); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + SessionDescription localSdp = transport.pc.getLocalDescription(); + JsonObject localSdpObj = Parser.parse(localSdp.description); + + if(!transport.ready){ + transport.SetupTransport("", localSdpObj); + } + +// var localSdpStr = Writer.write(localSdpObj); +// Log.d(TAG,localSdpStr); +// Log.d(TAG,localSdpObj.get("media").getAsJsonArray().get(0).getAsJsonObject().get("ssrcs").toString()); + + JsonObject offerMediaObject = localSdpObj.getAsJsonArray("media").get(mediaSectionIdx.idx).getAsJsonObject(); + + sendingRtpParameters.getAsJsonObject("rtcp").addProperty("cname", Utils.getCname(offerMediaObject)); + + sendingRtpParameters.add("encodings", Utils.getRtpEncodings(offerMediaObject)); + + Log.d(TAG,"codec:"+sendingRemoteRtpParameters.getAsJsonArray("codecs").toString()); + // TODO: 2024/10/11 fix mid + + transport.remoteSdp.send(offerMediaObject, "", sendingRtpParameters, sendingRemoteRtpParameters, null); + Log.d(TAG, sendingRemoteRtpParameters.toString()); + String remoteSdpStr = transport.remoteSdp.getSdp(); + + + CompletableFuture futureSetRemote = new CompletableFuture<>(); + + transport.pc.setRemoteDescription(new Device.SDPObserverForRtpCaps(){ + @Override + public void onSetSuccess() { + futureSetRemote.complete(null); + } + + @Override + public void onSetFailure(String error) { + futureSetRemote.completeExceptionally(new Exception(error)); + } + },new SessionDescription(SessionDescription.Type.ANSWER,remoteSdpStr)); + + try { + futureSetRemote.get(); + + } catch (ExecutionException e) { + throw new RuntimeException(e); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + + this.produceData = new JsonObject(); + produceData.addProperty("kind",track.kind()); + produceData.add("rtpParameters",sendingRtpParameters); + //String producerId = onProduce.apply(produceData); + //Log.d(TAG,"pid:"+producerId); + } + + +} \ No newline at end of file diff --git a/app/src/main/java/com/example/neurology_project_android/sampledata/Transport.java b/app/src/main/java/com/example/neurology_project_android/sampledata/Transport.java new file mode 100644 index 0000000..89b3a6c --- /dev/null +++ b/app/src/main/java/com/example/neurology_project_android/sampledata/Transport.java @@ -0,0 +1,154 @@ +package com.example.neurology_project_android.sampledata; + +import android.util.Log; + +import androidx.annotation.Nullable; + +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; + +import org.json.JSONException; +import org.json.JSONObject; +import org.webrtc.DataChannel; +import org.webrtc.IceCandidate; +import org.webrtc.MediaStream; +import org.webrtc.MediaStreamTrack; +import org.webrtc.PeerConnection; +import org.webrtc.RtpReceiver; + +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.function.Consumer; + +import one.dugon.mediasoup_android_sdk.sdp.RemoteSdp; +import one.dugon.mediasoup_android_sdk.sdp.Utils; + + +public class Transport implements PeerConnection.Observer{ + private static final String TAG = "Transport"; + + public String id ; + public RemoteSdp remoteSdp; + + public Consumer onConnect; + public Consumer onTrack = null; + + @Nullable + public PeerConnection pc; + + public boolean ready = false; + public ExecutorService executor = Executors.newSingleThreadExecutor(); + + + public Transport(String id, + JsonObject iceParameters, + JsonArray iceCandidates, + JsonObject dtlsParameters){ + this.id = id; + this.remoteSdp = new RemoteSdp(iceParameters, iceCandidates, dtlsParameters, null); + } + + public void start(PeerConnection peerConnection) { + pc = peerConnection; + } + + public void SetupTransport(String localDtlsRole, JsonObject localSdpObject) throws JSONException { + + + // Get our local DTLS parameters. + Gson gson = new Gson(); + + + // 2. Serialize the Gson JsonObject into a raw JSON string + JsonObject dtlsParameters = Utils.extractDtlsParameters(localSdpObject); + + + dtlsParameters.addProperty("role","client"); + String toConvert = gson.toJson(dtlsParameters); + Log.e("TRANSPORT:", "TO CONVERT STRING " + toConvert); + onConnect.accept(new JSONObject(toConvert)); + // Set our DTLS role. +// dtlsParameters["role"] = localDtlsRole; + + // Update the remote DTLS role in the SDP. +// var remoteDtlsRole = localDtlsRole.equals("client") ? "server" : "client"; +// this->remoteSdp->UpdateDtlsRole(remoteDtlsRole); + remoteSdp.updateDtlsRole("server"); + + // May throw. +// this->privateListener->OnConnect(dtlsParameters); + ready = true; + } + + + @Override + public void onSignalingChange(PeerConnection.SignalingState signalingState) { + + } + + @Override + public void onIceConnectionChange(PeerConnection.IceConnectionState iceConnectionState) { + Log.d(TAG,"iceState:"+iceConnectionState.name()); + } + + @Override + public void onIceConnectionReceivingChange(boolean b) { + + } + + @Override + public void onIceGatheringChange(PeerConnection.IceGatheringState iceGatheringState) { + + } + + @Override + public void onIceCandidate(IceCandidate iceCandidate) { + + } + + @Override + public void onIceCandidatesRemoved(IceCandidate[] iceCandidates) { + + } + + @Override + public void onAddStream(MediaStream mediaStream) { + + } + + @Override + public void onRemoveStream(MediaStream mediaStream) { + + } + + @Override + public void onDataChannel(DataChannel dataChannel) { + + } + + @Override + public void onRenegotiationNeeded() { + + } + + @Override + public void onAddTrack(RtpReceiver receiver, MediaStream[] mediaStreams) { +// if(onTrack != null){ +// Log.d(TAG,"onAddTrack " + receiver.track().kind()); +// +// if(Objects.equals(receiver.track().kind(), "video")) { +// onTrack.accept(receiver.track().id()); +// tracks.put(receiver.track().id(),receiver.track()); +// } +// } + if(onTrack != null){ + onTrack.accept(receiver.track()); + } + } + + @Override + public void onRemoveTrack(RtpReceiver receiver) { + Log.d(TAG,"onRemoveTrack"); + } +} \ No newline at end of file diff --git a/gradle.properties b/gradle.properties index 20e2a01..2f6baf7 100644 --- a/gradle.properties +++ b/gradle.properties @@ -20,4 +20,5 @@ kotlin.code.style=official # Enables namespacing of each library's R class so that its R class includes only the # resources declared in the library itself and none from the library's dependencies, # thereby reducing the size of the R class for that library -android.nonTransitiveRClass=true \ No newline at end of file +android.nonTransitiveRClass=true +android.enableJetifier=true \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f2c6bf6..3e8290a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -12,7 +12,7 @@ navigationCompose = "2.8.5" firebaseDatabaseKtx = "21.0.0" constraintlayout = "2.2.0" media3CommonKtx = "1.5.1" -cameraCore = "1.4.2" +cameraCore = "1.5.1" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } From 145f2c40615022677ae96b1600f168ecc56c4bc2 Mon Sep 17 00:00:00 2001 From: deHank Date: Tue, 21 Oct 2025 09:20:11 -0400 Subject: [PATCH 05/14] saving progress --- .../neurology_project_android/ProducerInfo.kt | 6 ++ .../neurology_project_android/RoomClient.kt | 56 +++++++++++++++++- .../SignalingClient.kt | 59 ++++++++++++++++++- 3 files changed, 117 insertions(+), 4 deletions(-) create mode 100644 app/src/main/java/com/example/neurology_project_android/ProducerInfo.kt diff --git a/app/src/main/java/com/example/neurology_project_android/ProducerInfo.kt b/app/src/main/java/com/example/neurology_project_android/ProducerInfo.kt new file mode 100644 index 0000000..23e5ad5 --- /dev/null +++ b/app/src/main/java/com/example/neurology_project_android/ProducerInfo.kt @@ -0,0 +1,6 @@ +package com.example.neurology_project_android + +data class ProducerInfo( + val producer_id: String, + val producer_socket_id: String // Not used in the loop, but good to include +) diff --git a/app/src/main/java/com/example/neurology_project_android/RoomClient.kt b/app/src/main/java/com/example/neurology_project_android/RoomClient.kt index 3cc8553..d451b2a 100644 --- a/app/src/main/java/com/example/neurology_project_android/RoomClient.kt +++ b/app/src/main/java/com/example/neurology_project_android/RoomClient.kt @@ -5,6 +5,7 @@ import androidx.annotation.OptIn import androidx.media3.common.util.Log import androidx.media3.common.util.UnstableApi import com.example.neurology_project_android.sampledata.Device +import com.example.neurology_project_android.sampledata.RecvTransport import com.google.gson.Gson import com.google.gson.JsonArray import com.google.gson.JsonObject @@ -13,13 +14,15 @@ import io.socket.client.Socket import org.json.JSONArray import org.json.JSONObject +import org.webrtc.MediaStreamTrack import java.util.function.Consumer import java.util.function.Function class RoomClient constructor(room_id: String, name: String, socket: Socket, context: Context) { + private lateinit var consumerTransportId: String private var localMedia = null; private var remoteMedia = null; - + private lateinit var consumerTransport:RecvTransport private lateinit var socket: Socket private lateinit var sendTransportId:String @@ -157,6 +160,50 @@ class RoomClient constructor(room_id: String, name: String, socket: Socket, con } }) + var newJsonPayload = JSONObject().apply { put("forceTcp", "false") + } + + socket.emit("createWebRtcTransport", newJsonPayload, Ack { args -> + + // This block runs when the server executes 'callback(roomList)' + + if (args.isEmpty() || args[0] == null) { + Log.e("SIGNALING CLIENT", "no router rtpCaps") + return@Ack + } + + // Assuming the room list is the first argument in the callback's arguments array + val responseData = args[0] + + if (responseData is JSONObject) { + + + Log.d("SIGNALING CLIENT", "SUCCESS! recv transport created: $responseData") + // 1. Initialize a mutable list to hold the extracted room IDs + + + val gson = Gson() + this.consumerTransportId = responseData.get("id").toString() + this.consumerTransport = Device.createRecvTransport(responseData.get("id").toString(), + gson.fromJson(responseData.get("iceParameters").toString(), JsonObject::class.java) , + gson.fromJson(responseData.get("iceCandidates").toString(), JsonArray::class.java), gson.fromJson(responseData.get("dtlsParameters").toString(), JsonObject::class.java) + ) + + this.consumerTransport.onConnect = Consumer { dtlsParameters: JSONObject -> + Log.d("ROOM CLIENT", "ON CONNECT") + } + this.consumerTransport.onTrack = Consumer { track: MediaStreamTrack -> + Log.d("ROOM CLIENT", "ON TRACK") + } + + this.consumerTransport.receive(consumerTransportId, "audio", Device.rtpCapabilities) + + // TODO: Update ViewModel/Activity state here + + } else { + Log.e("SIGNALING CLIENT", "Received unexpected response type: ${responseData.javaClass.name}") + } + }) @@ -286,6 +333,13 @@ class RoomClient constructor(room_id: String, name: String, socket: Socket, con }) } + fun consume(producerId: String) { + + var payload = JSONObject().apply { put("producerId", producerId) + put("rtpCapabilities", Device.rtpCapabilities, )} + this.socket.emit("consume") + } + private var device = Device() init { this.socket = socket diff --git a/app/src/main/java/com/example/neurology_project_android/SignalingClient.kt b/app/src/main/java/com/example/neurology_project_android/SignalingClient.kt index b279430..12ed75f 100644 --- a/app/src/main/java/com/example/neurology_project_android/SignalingClient.kt +++ b/app/src/main/java/com/example/neurology_project_android/SignalingClient.kt @@ -1,5 +1,6 @@ package com.example.neurology_project_android +import android.content.ContentValues.TAG import android.content.Context import android.hardware.camera2.CameraManager import android.media.AudioManager @@ -10,10 +11,16 @@ import androidx.annotation.RequiresApi import androidx.compose.runtime.remember import androidx.media3.common.util.Log import androidx.media3.common.util.UnstableApi +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken import io.socket.client.Ack import io.socket.client.IO import io.socket.client.Manager import io.socket.client.Socket +import io.socket.emitter.Emitter +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import okhttp3.FormBody import okhttp3.OkHttpClient import okhttp3.Request @@ -60,6 +67,7 @@ class SignalingClient @OptIn(UnstableApi::class) constructor private lateinit var client: OkHttpClient private lateinit var mediaID: String private lateinit var webSocket: WebSocket + private lateinit var currentRoomClient: RoomClient // private lateinit var localSDP: SessionDescription // private lateinit var track: VideoTrack // private val server = @@ -119,7 +127,7 @@ class SignalingClient @OptIn(UnstableApi::class) constructor } fun joinRoom(room_id: String){ - var currentRoomClient = RoomClient(room_id, "david_android", socket, context = this.context) + currentRoomClient = RoomClient(room_id, "david_android", socket, context = this.context) } @@ -180,8 +188,8 @@ class SignalingClient @OptIn(UnstableApi::class) constructor }) } - - + private val gson = Gson() + private val producerMap: MutableMap = mutableMapOf() init { this.context = context var sessionManager = SessionManager(context) @@ -202,7 +210,52 @@ class SignalingClient @OptIn(UnstableApi::class) constructor try{ socket = IO.socket("https://meechie.techkit.xyz:3016", options) + // The Emitter.Listener callback runs on a background thread. + socket.on("newProducers", Emitter.Listener { args -> + + // 1. Get the JSONArray containing the list of producer objects + val dataArray = args.getOrNull(0) as? JSONArray + + if (dataArray == null || dataArray.length() == 0) { + Log.w(TAG, "Received newProducers event but data array was empty or null.") + return@Listener + } + + // 2. Launch a coroutine to handle the asynchronous consumption loop + // We use Dispatchers.IO for networking/blocking operations. + CoroutineScope(Dispatchers.IO).launch { + + // --- CONVERSION --- + // Convert the org.json.JSONArray to a List using Gson/TypeToken + val listType = object : TypeToken>() {}.type + // Note: Since JSONArray doesn't have a direct toString() that Gson handles perfectly + // across all Android versions, we convert to string and parse. + val producerInfoList: List = gson.fromJson(dataArray.toString(), listType) + + Log.d(TAG, "Attempting to consume ${producerInfoList.size} new producers.") + + // --- CONSUMPTION LOOP --- + for (info in producerInfoList) { + + val producerId = info.producer_id + + // 3. Check if we already created this producer (optional self-check) + if (!producerMap.containsKey(producerId)) { + try { + // 4. Await the asynchronous consumption method + // This method (which you need to implement) handles signaling and transport setup + currentRoomClient.consume(producerId) + + Log.i(TAG, "Successfully consumed stream for Producer ID: $producerId") + + } catch (e: Exception) { + Log.e(TAG, "Failed to consume producer $producerId", e) + } + } + } + } + }) }catch(e: Error){ Log.d("SOCKET ERROR:" ,e.toString()) From 839e51c0c08c21f0e83a96eca5c264a05f875a61 Mon Sep 17 00:00:00 2001 From: David Date: Tue, 18 Nov 2025 16:14:11 -0500 Subject: [PATCH 06/14] Working stroke form submission! --- app/build.gradle.kts | 6 + app/src/main/AndroidManifest.xml | 4 +- .../neurology_project_android/AppModule.kt | 39 ++ .../CallScreenActivity.kt | 13 +- .../neurology_project_android/FormManager.kt | 67 +- .../ListNIHFormActivity.kt | 14 +- .../neurology_project_android/MainActivity.kt | 221 ++++--- .../MainViewModel.kt | 101 +++ .../MyApplication.kt | 10 + .../neurology_project_android/NIHForm.kt | 89 ++- .../neurology_project_android/NIHFormModel.kt | 191 ++++++ .../NewNIHFormActivity.kt | 599 +++++------------- .../NewNIHFormViewModel.kt | 160 +++++ .../neurology_project_android/RoomClient.kt | 303 ++++++--- .../SavedNIHFormActivity.kt | 66 +- .../SignalingClient.kt | 134 ++-- .../SignalingRepository.kt | 12 + .../sampledata/Device.java | 9 +- .../sampledata/LocalVideoSource.java | 13 +- .../sampledata/interfaceExample/Car.java | 45 ++ .../sampledata/interfaceExample/Drivable.java | 10 + .../sampledata/interfaceExample/Example.java | 35 + .../sampledata/interfaceExample/Person.java | 24 + .../sampledata/interfaceExample/Vehicle.java | 41 ++ build.gradle.kts | 12 +- gradle/libs.versions.toml | 2 + 26 files changed, 1450 insertions(+), 770 deletions(-) create mode 100644 app/src/main/java/com/example/neurology_project_android/AppModule.kt create mode 100644 app/src/main/java/com/example/neurology_project_android/MainViewModel.kt create mode 100644 app/src/main/java/com/example/neurology_project_android/MyApplication.kt create mode 100644 app/src/main/java/com/example/neurology_project_android/NIHFormModel.kt create mode 100644 app/src/main/java/com/example/neurology_project_android/NewNIHFormViewModel.kt create mode 100644 app/src/main/java/com/example/neurology_project_android/SignalingRepository.kt create mode 100644 app/src/main/java/com/example/neurology_project_android/sampledata/interfaceExample/Car.java create mode 100644 app/src/main/java/com/example/neurology_project_android/sampledata/interfaceExample/Drivable.java create mode 100644 app/src/main/java/com/example/neurology_project_android/sampledata/interfaceExample/Example.java create mode 100644 app/src/main/java/com/example/neurology_project_android/sampledata/interfaceExample/Person.java create mode 100644 app/src/main/java/com/example/neurology_project_android/sampledata/interfaceExample/Vehicle.java diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 2801b25..12b8de4 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -3,6 +3,8 @@ plugins { alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.compose) id("com.google.devtools.ksp") + id("com.google.dagger.hilt.android") + kotlin("plugin.serialization").version("2.2.21") } android { @@ -100,6 +102,10 @@ android { } dependencies { + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0") + implementation("com.google.dagger:hilt-android:2.57.1") + implementation(libs.androidx.runtime) + ksp("com.google.dagger:hilt-android-compiler:2.57.2") implementation ("com.github.0-u-0:mediasoup-android-sdk:0.0.1") implementation("com.github.0-u-0:dugon-webrtc-android:100.0.2") implementation("io.socket:socket.io-client:2.1.1") diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index c925b25..d30a73c 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -15,7 +15,9 @@ - + Unit) { - val json = JSONObject().apply { - put("patientName", form.patientName) - put("patientDob", form.dob) - put("formDate", form.date) - put("results", form.formData) - put("username", form.username) - } - + val jsonString = Gson().toJson(form) + Log.d(TAG, "values are " + form.toString()) val requestBody = RequestBody.create( "application/json; charset=utf-8".toMediaTypeOrNull(), - json.toString() + jsonString ) val request = Request.Builder() - .url("https://videochat-signaling-app.ue.r.appspot.com/key=peerjs/post") + .url("https://meechie.techkit.xyz:3016/key=peerjs/post") .post(requestBody) .addHeader("Content-Type", "application/json") - .addHeader("Action", "submitStrokeScale") + .addHeader("Action", "start_new_nihss_form") .build() client.newCall(request).enqueue(object : Callback { override fun onFailure(call: Call, e: IOException) { + Log.e(TAG, e.toString()) onResult(false) } override fun onResponse(call: Call, response: Response) { + Log.d(TAG, response.toString()) onResult(response.isSuccessful) } }) @@ -61,7 +58,7 @@ object FormManager { ) val request = Request.Builder() - .url("https://videochat-signaling-app.ue.r.appspot.com/key=peerjs/post") + .url("https://meechie.techkit.xyz:3016/key=peerjs/post") .post(requestBody) .addHeader("Content-Type", "application/json") .addHeader("Action", "getUsersForms") @@ -75,28 +72,28 @@ object FormManager { for (i in 0 until jsonArray.length()) { val item = jsonArray.getJSONObject(i) - forms.add( - NIHForm( - id = item.getInt("id"), - patientName = item.getString("patient_name"), - dob = item.getString("patient_dob"), - date = item.getString("form_date"), - formData = item.getString("results"), - username = item.getString("username") - ) - ) +// forms.add( +// NIHForm( +// form_id = UUID.randomUUID(), +//// patientName = item.getString("patient_name"), +//// dob = item.getString("patient_dob"), +//// date = item.getString("form_date"), +//// formData = item.getString("results"), +// username = item.getString("username") +// ) +// ) } } else { Log.e("FORM_MANAGER", "Server error: ${response.code}") } - } catch (e: IOException) { - Log.e("FORM_MANAGER", "Network error: ${e.message}") + } catch (e: Exception) { + Log.e("FORM_MANAGER", "Error opening form: ${e.message}") } return@withContext forms } - fun deleteForm(formId: Int, username: String, client: OkHttpClient, callback: (Boolean) -> Unit) { + fun deleteForm(formId: UUID, username: String, client: OkHttpClient, callback: (Boolean) -> Unit) { val json = JSONObject().apply { put("id", formId) put("username", username) @@ -108,7 +105,7 @@ object FormManager { ) val request = Request.Builder() - .url("https://videochat-signaling-app.ue.r.appspot.com/key=peerjs/post") + .url("https://meechie.techkit.xyz:3016/key=peerjs/post") .post(requestBody) .addHeader("Content-Type", "application/json") .addHeader("Action", "deleteForm") @@ -129,12 +126,12 @@ object FormManager { fun updateForm(form: NIHForm, client: OkHttpClient, onComplete: (Boolean) -> Unit) { val json = JSONObject().apply { - put("id", form.id) - put("patientName", form.patientName) - put("patientDob", form.dob) - put("formDate", form.date) - put("results", form.formData) - put("username", form.username) +// put("id", form.id) +// put("patientName", form.patientName) +// put("patientDob", form.dob) +// put("formDate", form.date) +// put("results", form.formData) +// put("username", form.username) } val requestBody = RequestBody.create( @@ -143,7 +140,7 @@ object FormManager { ) val request = Request.Builder() - .url("https://videochat-signaling-app.ue.r.appspot.com/key=peerjs/post") + .url("https://meechie.techkit.xyz:3016/key=peerjs/post") .post(requestBody) .addHeader("Content-Type", "application/json") .addHeader("Action", "updateForm") diff --git a/app/src/main/java/com/example/neurology_project_android/ListNIHFormActivity.kt b/app/src/main/java/com/example/neurology_project_android/ListNIHFormActivity.kt index 23bfa1d..5dea015 100644 --- a/app/src/main/java/com/example/neurology_project_android/ListNIHFormActivity.kt +++ b/app/src/main/java/com/example/neurology_project_android/ListNIHFormActivity.kt @@ -87,15 +87,15 @@ fun ListNIHFormScreen(refreshTrigger: Int) { ) { items(savedForms) { form -> SavedFormItem( - form = SavedForm(form.patientName, form.date), + form = SavedForm(form.patientName!!, form.formDate!!), onClick = { val intent = Intent(context, SavedNIHFormActivity::class.java).apply { - putExtra("formId", form.id) - putExtra("patientName", form.patientName) - putExtra("dob", form.dob) - putExtra("date", form.date) - putExtra("formData", form.formData) - putExtra("username", form.username) +// putExtra("formId", form.id) +// putExtra("patientName", form.patientName) +// putExtra("dob", form.dob) +// putExtra("date", form.date) +// putExtra("formData", form.formData) +// putExtra("username", form.username) } context.startActivity(intent) } diff --git a/app/src/main/java/com/example/neurology_project_android/MainActivity.kt b/app/src/main/java/com/example/neurology_project_android/MainActivity.kt index ff2de00..4e830e4 100644 --- a/app/src/main/java/com/example/neurology_project_android/MainActivity.kt +++ b/app/src/main/java/com/example/neurology_project_android/MainActivity.kt @@ -13,6 +13,7 @@ import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge +import androidx.activity.viewModels import androidx.annotation.OptIn import androidx.annotation.RequiresApi import androidx.camera.core.imagecapture.CameraRequest @@ -44,6 +45,7 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -61,11 +63,15 @@ import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import com.example.neurology_project_android.ui.theme.NeurologyProjectAndroidTheme +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.CoroutineScope import okhttp3.OkHttpClient import okhttp3.Request import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext + +@AndroidEntryPoint class MainActivity : ComponentActivity() { @@ -77,8 +83,8 @@ class MainActivity : ComponentActivity() { private var isInCall by mutableStateOf(false) private var cameraInitialized by mutableStateOf(false) private lateinit var signalingClient: SignalingClient - - + private lateinit var signalingRepository: SignalingRepository + private val viewModel: MainViewModel by viewModels() @SuppressLint("UnusedMaterial3ScaffoldPaddingParameter") @RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM) @@ -101,61 +107,37 @@ class MainActivity : ComponentActivity() { ), 1 ) - + enableEdgeToEdge() setContent { NeurologyProjectAndroidTheme { - val userIdState = remember { mutableStateOf(null) } - val peersState = remember { mutableStateOf>(emptyList()) } - - // Fetch user ID once - LaunchedEffect(Unit) { - val fetchedId = fetchUserId() - userIdState.value = fetchedId - - //Now safe to start SignalingClient - signalingClient = SignalingClient( - this@MainActivity, - - { peers -> - runOnUiThread { - peersState.value = peers.filter { it != fetchedId } + viewModel.connectSignalingClient() + val uiState by viewModel.uiState.collectAsState() + // 3. OBSERVE state from the ViewModel + Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> + when (val state = uiState) { + is MainUiState.Loading -> { + Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) { + CircularProgressIndicator() } } - - ) - -// // Fetch peers -// GetPeers { peers -> -// runOnUiThread { -// peersState.value = peers.filter { it != fetchedId } -// } -// } - } - - val userId = userIdState.value - - if (userId == null) { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - CircularProgressIndicator() - } - } else { - Scaffold( - modifier = Modifier.fillMaxSize(), - containerColor = Color.Transparent, - content = { - innerPadding -> - - myApp(peersState.value, innerPadding) + is MainUiState.Error -> { + Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) { + Text("Error: ${state.message}") + } } - - - ) + is MainUiState.Success -> { + // Pass the state down to your UI + MyApp( + userId = state.userId, + peers = state.peers, + innerPadding = innerPadding, + onJoinRoom = { targetId -> viewModel.joinRoom(targetId) } + ) + } + } } } } @@ -233,44 +215,47 @@ class MainActivity : ComponentActivity() { } } + // All your other @Composable functions should be updated to accept the data and lambdas they need + // For example, MyApp now takes the userId, peers, and onJoinRoom lambda @RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM) @Composable - fun myApp(peers: List, innerPadding: PaddingValues) { - - var navController = rememberNavController() - + fun MyApp( + userId: String, + peers: List, + innerPadding: PaddingValues, + onJoinRoom: (String) -> Unit // FIX #1: This is a simple function type, not @Composable + ) { + val navController = rememberNavController() NavHost(navController, startDestination = "home") { composable("home") { HomeScreen( - onNavigateToProfile = { navController.navigate("callScreen") }, modifier = Modifier.padding(innerPadding), - peerId = "VR CLIENT", - peers = peers + userId = userId, // Pass state down + peers = peers, // Pass state down + onJoinRoom = onJoinRoom, // Pass the event handler down + onNavigateToCallScreen = { navController.navigate("callScreen") } ) - - // A simple loading/home screen - //Greeting() } composable("callScreen") { + // Assuming CallScreen is defined elsewhere CallScreen() } } - } @RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM) @Composable - fun HomeScreen(modifier: Modifier = Modifier, peerId: String, peers: List, onNavigateToProfile: () -> Unit) { + fun HomeScreen( + modifier: Modifier = Modifier, + userId: String, + peers: List, + onJoinRoom: (String) -> Unit, + onNavigateToCallScreen: () -> Unit + ) { val context = LocalContext.current - - val sessionManager = remember { SessionManager(context) } - // Refresh UI every 3 seconds - LaunchedEffect(peers) { - // This will trigger recomposition whenever peers update - } Column( modifier = modifier @@ -278,27 +263,24 @@ class MainActivity : ComponentActivity() { .padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally ) { - Row( - modifier = Modifier - .fillMaxWidth(), - horizontalArrangement = Arrangement.End - ) { - TextButton( - onClick = { - sessionManager.logout() - val intent = Intent(context, LoginActivity::class.java) - intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK - context.startActivity(intent) + // Logout Button + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { + TextButton(onClick = { + sessionManager.logout() + val intent = Intent(context, LoginActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK } - ) { + context.startActivity(intent) + }) { Text("Log Out", color = MaterialTheme.colorScheme.primary) } } - Spacer(modifier = Modifier.height(8.dp)) - PeerIdSection("VR CLIENT") // Displays the correct Peer ID + // Peer ID Section + PeerIdSection(peerId = userId) + // Online Now Section (Scrollable) Column( modifier = Modifier .fillMaxWidth() @@ -306,9 +288,14 @@ class MainActivity : ComponentActivity() { .verticalScroll(rememberScrollState()), horizontalAlignment = Alignment.CenterHorizontally ) { - OnlineNowSection(peers,onNavigateToProfile) // No need for additional state + OnlineNowSection( + peers = peers, + onJoinRoom = onJoinRoom, + onNavigateToCallScreen = onNavigateToCallScreen + ) } + // NIH Forms Button NIHFormsButton() } } @@ -334,32 +321,64 @@ suspend fun fetchUserId(): String { } } - - - @RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM) @Composable -fun Greeting( - name: String, - modifier: Modifier = Modifier, - signalingClient: SignalingClient, - cameraInitialized: Boolean, - @SuppressLint("RestrictedApi") cameraRequest: () -> CameraRequest, - isInCall: Boolean +fun OnlineNowSection( + peers: List, + onJoinRoom: (String) -> Unit, + onNavigateToCallScreen: () -> Unit ) { + Text( + text = "Online Now:", + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(vertical = 8.dp) + ) + if (peers.isEmpty()) { + Text(text = "No peers online", modifier = Modifier.padding(16.dp)) + } else { + peers.forEach { peerId -> + OnlineUserCard( + userId = peerId, + onJoinClick = { + // FIX #2: Chain the events. Call ViewModel then navigate. + onJoinRoom(peerId) + onNavigateToCallScreen() + } + ) + } + } +} - -// LaunchedEffect(isInCall) { -// if (isInCall) { -// navController.navigate("callScreen") -// } else { -// navController.navigate("home") // Navigate back when call ends -// } -// } +@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM) +@Composable +fun OnlineUserCard(userId: String, onJoinClick: () -> Unit) { // FIX #3: Simplified parameter + Card( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp) + .shadow(4.dp, RoundedCornerShape(8.dp)), + shape = RoundedCornerShape(8.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text(text = userId, modifier = Modifier.weight(1f).padding(end = 16.dp)) + Button( + onClick = onJoinClick, // Use the simplified lambda + modifier = Modifier.wrapContentWidth() + ) { + Text(text = "Call") + } + } + } +} -} diff --git a/app/src/main/java/com/example/neurology_project_android/MainViewModel.kt b/app/src/main/java/com/example/neurology_project_android/MainViewModel.kt new file mode 100644 index 0000000..81d5f34 --- /dev/null +++ b/app/src/main/java/com/example/neurology_project_android/MainViewModel.kt @@ -0,0 +1,101 @@ +// In: app/src/main/java/com/example/neurology_project_android/MainViewModel.kt + +package com.example.neurology_project_android + +import android.os.Build +import android.util.Log +import androidx.annotation.RequiresApi + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request +import javax.inject.Inject + +@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM) +@HiltViewModel +class MainViewModel @Inject constructor( + private val signalingClient: SignalingClient +) : ViewModel() { + + // --- State Management --- + private val _uiState = MutableStateFlow(MainUiState.Loading) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + // Start the process as soon as the ViewModel is created + initialize() + } + + @RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM) + private fun initialize() { + viewModelScope.launch { + _uiState.value = MainUiState.Loading + val userId = fetchUserId() + if (userId == "unknown") { + _uiState.value = MainUiState.Error("Could not fetch user ID.") + return@launch + } + + + // 1. Connect the client +// signalingClient.connectClient(userId) + + // 2. Start collecting the peer list flow + signalingClient.peerListFlow + .catch { exception -> + // Handle any errors from the flow + _uiState.value = MainUiState.Error(exception.message ?: "An error occurred") + } + .collect { peers -> + Log.e("MainViewModel", "Received peer list: $peers") + + // 3. Every time a new peer list arrives, update the UI state + // The current user is already filtered on the server in this example, + // but filtering here is a good safeguard. + val otherPeers = peers.filter { it != userId } + _uiState.value = MainUiState.Success(userId, otherPeers) + } + } + } + // Expose repository methods for the UI to call + @RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM) + fun joinRoom(targetUserId: String) { + signalingClient.joinRoom(targetUserId) + } + + // This logic is now self-contained within the ViewModel + private suspend fun fetchUserId(): String { + return withContext(Dispatchers.IO) { + try { + val idUrl = "https://videochat-signaling-app.ue.r.appspot.com/key=peerjs/id" + val client = OkHttpClient() + val request = Request.Builder().url(idUrl).build() + val response = client.newCall(request).execute() + response.body?.string() ?: "unknown" + } catch (e: Exception) { + "unknown" + } + } + } + + @RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM) + fun connectSignalingClient() { + signalingClient.connectClient() + } +} + +// Sealed interface to represent the different UI states +sealed interface MainUiState { + object Loading : MainUiState + data class Success(val userId: String, val peers: List) : MainUiState + data class Error(val message: String) : MainUiState +} diff --git a/app/src/main/java/com/example/neurology_project_android/MyApplication.kt b/app/src/main/java/com/example/neurology_project_android/MyApplication.kt new file mode 100644 index 0000000..e92cb82 --- /dev/null +++ b/app/src/main/java/com/example/neurology_project_android/MyApplication.kt @@ -0,0 +1,10 @@ + +package com.example.neurology_project_android + +import android.app.Application +import dagger.hilt.android.HiltAndroidApp + +@HiltAndroidApp +class MyApplication : Application() { + // The body can be empty. Hilt uses this annotation for setup. +} \ No newline at end of file diff --git a/app/src/main/java/com/example/neurology_project_android/NIHForm.kt b/app/src/main/java/com/example/neurology_project_android/NIHForm.kt index f3c1dea..682b7d0 100644 --- a/app/src/main/java/com/example/neurology_project_android/NIHForm.kt +++ b/app/src/main/java/com/example/neurology_project_android/NIHForm.kt @@ -2,13 +2,86 @@ package com.example.neurology_project_android import androidx.room.Entity import androidx.room.PrimaryKey +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import java.util.UUID -@Entity(tableName = "nih_forms") + + + +@Serializable data class NIHForm( - @PrimaryKey(autoGenerate = true) val id: Int = 0, - val patientName: String, - val dob: String, - val date: String, - val formData: String, - val username: String -) \ No newline at end of file + + + // Other string/text fields (all nullable) + @SerialName("username") + val username: String? = null, + + @SerialName("form_date") + val formDate: String? = null, // You can parse this into a Date object later + + @SerialName("patient_name") + val patientName: String? = null, + + @SerialName("sessionid") + val sessionId: String? = null, + + // --- NIHSS Item Scores (all nullable) --- + + @SerialName("item_1a_loc_level") + val item1aLocLevel: Int? = null, + + @SerialName("item_1b_loc_commands") + val item1bLocCommands: Int? = null, + + @SerialName("item_1c_loc_best_gaze") + val item1cLocBestGaze: Int? = null, + + @SerialName("item_2_best_motor_gaze") // As per your schema + val item2BestMotorGaze: Int? = null, + + @SerialName("item_3_visual") + val item3Visual: Int? = null, + + @SerialName("item_4_facial_palsy") + val item4FacialPalsy: Int? = null, + + @SerialName("item_5_left_arm_motor") + val item5LeftArmMotor: Int? = null, + + @SerialName("item_6_right_arm_motor") + val item6RightArmMotor: Int? = null, + + @SerialName("item_7_left_leg_motor") + val item7LeftLegMotor: Int? = null, + + @SerialName("item_8_right_leg_motor") + val item8RightLegMotor: Int? = null, + + @SerialName("item_9_ataxia") + val item9Ataxia: Int? = null, + + @SerialName("item_10_sensory") + val item10Sensory: Int? = null, + + @SerialName("item_11_language") + val item11Language: Int? = null, + + @SerialName("item_12_dysarthria") + val item12Dysarthria: Int? = null, + + @SerialName("item_13_extinction_inattention") + val item13ExtinctionInattention: Int? = null, + + // --- Total Score --- + + @SerialName("total_nihss_score") + val totalNihssScore: Int? = null + + +) + + public fun NIHForm.toJson(): String { + return Json.encodeToString(this) + } \ No newline at end of file diff --git a/app/src/main/java/com/example/neurology_project_android/NIHFormModel.kt b/app/src/main/java/com/example/neurology_project_android/NIHFormModel.kt new file mode 100644 index 0000000..ec8733f --- /dev/null +++ b/app/src/main/java/com/example/neurology_project_android/NIHFormModel.kt @@ -0,0 +1,191 @@ +// In a new file: app/src/main/java/com/example/neurology_project_android/NIHFormModel.kt + +package com.example.neurology_project_android + +// Represents a single answer option for a question +data class FormOption( + val displayText: String, // The text shown in the UI, e.g., "Both arms held for 10 seconds" + val score: Int // The value stored for this option, e.g., 0 +) + +// Represents a single question in the NIH Stroke Scale +data class FormQuestion( + val id: Int, // A unique identifier, e.g., "1a" + val questionText: String, // The main question header, e.g., "1a. Level of Consciousness" + val instructionText: String, // The sub-header or instructions + val options: List // The list of possible answers for this question +) + +// This object holds the complete definition of your form, replacing the old StrokeScaleQuestions +object NIHFormModel { + val questions: List = listOf( + FormQuestion( + id = 1, + questionText = "1a. Level of Consciousness", + instructionText = "Assess the patient's response to stimulation.", + options = listOf( + FormOption("Alert; keenly responsive.", 0), + FormOption("Not alert, but arousable by minor stimulation.", 1), + FormOption("Not alert; requires repeated stimulation.", 2), + FormOption("Responds only with reflex motor or autonomic effects.", 3) + ) + ), + FormQuestion( + id = 2, + questionText = "1b. LOC Questions", + instructionText = "Ask the patient: What is the month? What is your age?", + options = listOf( + FormOption("Answers both questions correctly.", 0), + FormOption("Answers one question correctly.", 1), + FormOption("Answers neither question correctly.", 2) + ) + ), + FormQuestion( + id = 3, + questionText = "1c. LOC Commands", + instructionText = "Ask the patient to: Open and close your eyes. Grip and release your non-paretic hand.", + options = listOf( + FormOption("Performs both tasks correctly.", 0), + FormOption("Performs one task correctly.", 1), + FormOption("Performs neither task correctly.", 2) + ) + ), + FormQuestion( + id = 4, + questionText = "2. Best Gaze", + instructionText = "Test horizontal eye movements.", + options = listOf( + FormOption("Normal.", 0), + FormOption("Partial gaze palsy; gaze is abnormal in one or both eyes.", 1), + FormOption("Forced deviation, or total gaze paresis.", 2) + ) + ), + FormQuestion( + id = 5, + questionText = "3. Visual", + instructionText = "Test visual fields by confrontation (upper and lower quadrants).", + options = listOf( + FormOption("No visual loss.", 0), + FormOption("Partial hemianopia.", 1), + FormOption("Complete hemianopia.", 2), + FormOption("Bilateral hemianopia (blindness).", 3) + ) + ), + FormQuestion( + id = 6, + questionText = "4. Facial Palsy", + instructionText = "Ask patient to show teeth or raise eyebrows and close eyes.", + options = listOf( + FormOption("Normal symmetrical movements.", 0), + FormOption("Minor paralysis (flattened nasolabial fold, asymmetry on smiling).", 1), + FormOption("Partial paralysis (total or near-total paralysis of lower face).", 2), + FormOption("Complete paralysis of one or both sides (absence of facial movement in the upper and lower face).", 3) + ) + ), + FormQuestion( + id = 7, + questionText = "5. Motor Arm (Left)", + instructionText = "Extend the arm (palms down) 90 degrees (if sitting) or 45 degrees (if supine). Hold for 10 seconds.", + options = listOf( + FormOption("No drift; arm holds 90 (or 45) degrees for full 10 seconds.", 0), + FormOption("Drift; arm holds but drifts down before full 10 seconds.", 1), + FormOption("Some effort against gravity; arm cannot get to or maintain (if cued) 90 (or 45) degrees, drifts down to bed.", 2), + FormOption("No effort against gravity; arm falls.", 3), + FormOption("No movement.", 4), + FormOption("Amputation or joint fusion, explain:", 9) + ) + ), + FormQuestion( + id = 8, + questionText = "6. Motor Arm (Right)", + instructionText = "Extend the arm (palms down) 90 degrees (if sitting) or 45 degrees (if supine). Hold for 10 seconds.", + options = listOf( + FormOption("No drift; arm holds 90 (or 45) degrees for full 10 seconds.", 0), + FormOption("Drift; arm holds but drifts down before full 10 seconds.", 1), + FormOption("Some effort against gravity; arm cannot get to or maintain (if cued) 90 (or 45) degrees, drifts down to bed.", 2), + FormOption("No effort against gravity; arm falls.", 3), + FormOption("No movement.", 4), + FormOption("Amputation or joint fusion, explain:", 9) + ) + ), + FormQuestion( + id = 9, + questionText = "7. Motor Leg (Left)", + instructionText = "Raise the leg to 30 degrees (always supine). Hold for 5 seconds.", + options = listOf( + FormOption("No drift; leg holds 30-degree position for full 5 seconds.", 0), + FormOption("Drift; leg falls by end of the 5-second period but does not hit bed.", 1), + FormOption("Some effort against gravity; leg falls to bed by 5 seconds, but has some effort against gravity.", 2), + FormOption("No effort against gravity; leg falls to bed immediately.", 3), + FormOption("No movement.", 4), + FormOption("Amputation or joint fusion, explain:", 9) + ) + ), + FormQuestion( + id = 10, + questionText = "8. Motor Leg (Right)", + instructionText = "Raise the leg to 30 degrees (always supine). Hold for 5 seconds.", + options = listOf( + FormOption("No drift; leg holds 30-degree position for full 5 seconds.", 0), + FormOption("Drift; leg falls by end of the 5-second period but does not hit bed.", 1), + FormOption("Some effort against gravity; leg falls to bed by 5 seconds, but has some effort against gravity.", 2), + FormOption("No effort against gravity; leg falls to bed immediately.", 3), + FormOption("No movement.", 4), + FormOption("Amputation or joint fusion, explain:", 9) + ) + ), + FormQuestion( + id = 11, + questionText = "9. Limb Ataxia", + instructionText = "Test with finger-nose-finger and heel-shin tests on both sides.", + options = listOf( + FormOption("Absent.", 0), + FormOption("Present in one limb.", 1), + FormOption("Present in two limbs.", 2), + FormOption("Does not understand or is paralyzed.", 9) + ) + ), + FormQuestion( + id = 12, + questionText = "10. Sensory", + instructionText = "Test sensation or grimace to pinprick.", + options = listOf( + FormOption("Normal; no sensory loss.", 0), + FormOption("Mild-to-moderate sensory loss; less sharp/dull on affected side.", 1), + FormOption("Severe to total sensory loss; not aware of being touched.", 2) + ) + ), + FormQuestion( + id = 13, + questionText = "11. Best Language", + instructionText = "Ask patient to describe what is happening in the provided picture, name items, and read sentences.", + options = listOf( + FormOption("No aphasia; normal.", 0), + FormOption("Mild-to-moderate aphasia; some obvious loss of fluency or comprehension.", 1), + FormOption("Severe aphasia; all communication is fragmented, listener cannot understand.", 2), + FormOption("Mute, global aphasia; no usable speech or auditory comprehension.", 3) + ) + ), + FormQuestion( + id = 14, + questionText = "12. Dysarthria", + instructionText = "Ask the patient to read or repeat words from an attached list.", + options = listOf( + FormOption("Normal.", 0), + FormOption("Mild-to-moderate dysarthria; slurring of words but can be understood.", 1), + FormOption("Severe dysarthria; so slurred it is unintelligible or worse.", 2), + FormOption("Intubated or other physical barrier.", 9) + ) + ), + FormQuestion( + id = 15, + questionText = "13. Extinction and Inattention", + instructionText = "Sufficient information to identify neglect from prior testing is acceptable.", + options = listOf( + FormOption("No neglect.", 0), + FormOption("Visual, tactile, auditory, or personal inattention to one side.", 1), + FormOption("Profound hemi-inattention or extinction to more than one modality.", 2) + ) + ) + ) +} diff --git a/app/src/main/java/com/example/neurology_project_android/NewNIHFormActivity.kt b/app/src/main/java/com/example/neurology_project_android/NewNIHFormActivity.kt index 2b566a1..0c49422 100644 --- a/app/src/main/java/com/example/neurology_project_android/NewNIHFormActivity.kt +++ b/app/src/main/java/com/example/neurology_project_android/NewNIHFormActivity.kt @@ -1,490 +1,217 @@ package com.example.neurology_project_android -import android.content.Context -import android.content.Intent +import android.os.Build import android.os.Bundle -import android.util.Log import android.widget.Toast import androidx.activity.ComponentActivity import androidx.activity.compose.setContent +import androidx.activity.viewModels +import androidx.annotation.RequiresApi import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material3.* -import androidx.compose.runtime.* +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.semantics.getOrNull import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import kotlinx.coroutines.launch -import okhttp3.OkHttpClient -import java.text.SimpleDateFormat -import java.util.* - +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +// --- FIX 1: Import the new models --- +import com.example.neurology_project_android.NIHFormModel +import com.example.neurology_project_android.FormQuestion +import com.example.neurology_project_android.NewNIHFormViewModel +import com.example.neurology_project_android.SubmissionStatus +import dagger.hilt.android.AndroidEntryPoint + +// --- You can remove the old StrokeScaleQuestions import if it exists --- + // This annotation tells Hilt to manage dependencies for this Activity +@AndroidEntryPoint class NewNIHFormActivity : ComponentActivity() { + + // 1. Get the ViewModel directly from Hilt. + // The `by viewModels()` delegate handles everything for you. + private val viewModel: NewNIHFormViewModel by viewModels() + + @RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - val formId = intent.getIntExtra("formId", -1) - val patientName = intent.getStringExtra("patientName") - val dob = intent.getStringExtra("dob") - val date = intent.getStringExtra("date") - val formData = intent.getStringExtra("formData") - val username = intent.getStringExtra("username") - val sessionManager = SessionManager(this) // Create the real instance - val existingForm = if (patientName != null && dob != null && date != null && formData != null && username != null) { - NIHForm(formId, patientName, dob, date, formData, username) - } else null - + // Hilt provides the ViewModel and its dependencies automatically. + // No manual setup needed. setContent { - NewNIHFormScreen(existingForm, sessionManager) + // 2. Simply pass the Hilt-provided ViewModel to your screen. + NewNIHFormScreen(viewModel = viewModel) } } -} - -@Composable -fun NewNIHFormScreen(existingForm: NIHForm? = null, sessionManager: ISessionManager) { - val context = LocalContext.current - - val client = sessionManager.client - val coroutineScope = rememberCoroutineScope() - var refreshTrigger by remember { mutableStateOf(0) } - - var patientName by remember { mutableStateOf(existingForm?.patientName ?: "") } - var dob by remember { mutableStateOf(existingForm?.dob ?: "") } - val questions = remember { StrokeScaleQuestions.questions } - val selectedOptions = remember { - mutableStateListOf().apply { repeat(questions.size) { add(null) } } - } - - val keyboardController = LocalSoftwareKeyboardController.current - val dobCalendar = remember { Calendar.getInstance() } - val username = remember { sessionManager.fetchUsername() ?: "anonymous" } - - val date = remember { - existingForm?.date ?: SimpleDateFormat("MM/dd/yyyy", Locale.getDefault()).format(Date()) - } - // Populate selected options if editing - LaunchedEffect(existingForm) { - existingForm?.formData?.forEachIndexed { index, c -> - val score = c.toString().toIntOrNull() ?: 9 - selectedOptions[index] = if (score != 9) score else null + // This is the Composable function your Activity is trying to call. + @RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM) + @Composable + fun NewNIHFormScreen(viewModel: NewNIHFormViewModel) { + // 1. Observe state directly from the ViewModel + val patientName by viewModel.patientName + val itemScores by viewModel.itemScores + val submissionStatus by viewModel.submissionStatus.collectAsState() + val context = LocalContext.current + + // 2. React to changes in submissionStatus (e.g., show a toast, navigate away) + LaunchedEffect(submissionStatus) { + when (val status = submissionStatus) { + is SubmissionStatus.Success -> { + Toast.makeText(context, "Form submitted successfully!", Toast.LENGTH_SHORT).show() + // You could finish the activity upon success + // (context as? android.app.Activity)?.finish() + viewModel.resetSubmissionStatus() + } + is SubmissionStatus.Error -> { + Toast.makeText(context, status.message, Toast.LENGTH_LONG).show() + viewModel.resetSubmissionStatus() + } + else -> { /* Do nothing for Idle or Loading states here */ } + } } - } - val datePickerDialog = android.app.DatePickerDialog( - context, - { _, year, month, dayOfMonth -> - dobCalendar.set(year, month, dayOfMonth) - dob = SimpleDateFormat("MM/dd/yyyy", Locale.getDefault()).format(dobCalendar.time) - }, - dobCalendar.get(Calendar.YEAR), - dobCalendar.get(Calendar.MONTH), - dobCalendar.get(Calendar.DAY_OF_MONTH) - ) - - Column( - modifier = Modifier - .fillMaxSize() - .background(Color(0xFFF3E5F5)) - .padding(16.dp) - ) { + // 3. Define the UI structure Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally + modifier = Modifier + .fillMaxSize() + .padding(16.dp) + .background(MaterialTheme.colorScheme.background) ) { Text( text = "New NIH Stroke Scale Form", fontSize = 24.sp, fontWeight = FontWeight.Bold, - modifier = Modifier - .padding(top = 16.dp, bottom = 8.dp) + modifier = Modifier.padding(bottom = 16.dp) ) OutlinedTextField( value = patientName, - onValueChange = { patientName = it }, - label = { Text("Enter Patient Name") }, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 4.dp), - keyboardOptions = KeyboardOptions( - autoCorrect = false // Disables suggestions - ), - keyboardActions = KeyboardActions( - onDone = { keyboardController?.hide() } // Closes keyboard on Done press - ), - placeholder = { Text("") }, // Prevents showing hints - ) + // Send user input events up to the ViewModel + onValueChange = { viewModel.onPatientNameChange(it) }, + label = { Text("Patient Name") }, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(16.dp)) + + // A scrollable list for all the questions + LazyColumn(modifier = Modifier.weight(1f)) { + // --- FIX 2: Use the new NIHFormModel instead of StrokeScaleQuestions --- + itemsIndexed(NIHFormModel.questions) { index, question -> + QuestionCard( + question = question, + // Get the currently selected score for this question from the ViewModel's state + selectedScore = itemScores.getOrNull(index), + // When an option is clicked, notify the ViewModel with the question index and the option's score + onOptionClick = { score -> + viewModel.onScoreSelected(index, score) + } + ) + } + } - OutlinedTextField( - value = dob, - onValueChange = {}, - readOnly = true, - label = { Text("Date of Birth") }, + Button( + // When the button is clicked, call the submitForm function on the ViewModel + onClick = { viewModel.submitForm() }, + // Disable the button while the form is submitting + enabled = submissionStatus != SubmissionStatus.Loading, modifier = Modifier .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 4.dp) - .clickable { datePickerDialog.show() }, - enabled = false, //Disables editing, but still clickable due to Modifier - colors = OutlinedTextFieldDefaults.colors( - disabledBorderColor = MaterialTheme.colorScheme.outline - ) - - ) - - - Text( - text = "Date: $date", - fontSize = 16.sp, - color = Color.Gray, - modifier = Modifier - .padding(bottom = 16.dp) - ) - } - - LazyColumn( - modifier = Modifier.weight(1f) - ) { - items(questions) { question -> - QuestionCard(question, selectedOptions) + .padding(vertical = 8.dp) + ) { + if (submissionStatus == SubmissionStatus.Loading) { + CircularProgressIndicator(modifier = Modifier.size(24.dp), color = Color.White) + } else { + Text("Save Form") + } } } + } - Row( + /** + * A reusable Composable for displaying a single question, its options, and highlighting the selection. + */ + @Composable + fun QuestionCard(question: FormQuestion, selectedScore: Int?, onOptionClick: (Int) -> Unit) { // --- FIX 3: Use FormQuestion class --- + Card( modifier = Modifier .fillMaxWidth() - .padding(vertical = 16.dp), - horizontalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterHorizontally) + .padding(vertical = 8.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) ) { - Button( - onClick = { - if (patientName.isBlank()) { - Toast.makeText(context, "Please enter a patient name", Toast.LENGTH_SHORT).show() - return@Button - } - - coroutineScope.launch { - val formData = selectedOptions.joinToString("") { (it ?: 9).toString() } - val form = NIHForm( - patientName = patientName, - dob = dob, - date = date, - formData = formData, - username = username - ) - FormManager.submitFormToServer(form, client as OkHttpClient) { success -> - (context as? ComponentActivity)?.runOnUiThread { - if (success) { - Toast.makeText(context, "Form saved successfully", Toast.LENGTH_SHORT).show() - val intent = Intent(context, ListNIHFormActivity::class.java) - intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP - context.startActivity(intent) - } else { - Toast.makeText(context, "Failed to save form", Toast.LENGTH_SHORT).show() - } - } - } - } - }, - colors = ButtonDefaults.buttonColors(containerColor = Color.Green), - modifier = Modifier.weight(1f) - ) { - Text(text = "Save", color = Color.White) - } - - Button( - onClick = { - val intent = Intent(context, ListNIHFormActivity::class.java) - intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP - context.startActivity(intent) - }, - colors = ButtonDefaults.buttonColors(containerColor = Color.Red), - modifier = Modifier.weight(1f) + Column( + modifier = Modifier + .padding(16.dp) ) { - Text(text = "Cancel", color = Color.White) + // --- FIX 4: Use properties from the new FormQuestion data class --- + Text(text = question.questionText, fontWeight = FontWeight.Bold, fontSize = 18.sp) + if (question.instructionText.isNotEmpty()) { + Text( + text = question.instructionText, + fontSize = 14.sp, + color = Color.Gray, + modifier = Modifier.padding(bottom = 8.dp) + ) + } + + // Create a clickable row for each answer option + question.options.forEach { option -> + Row( + modifier = Modifier + .fillMaxWidth() + .background( + // Highlight the row if its score matches the selected score + if (selectedScore == option.score) MaterialTheme.colorScheme.primaryContainer else Color.Transparent + ) + .clickable { onOptionClick(option.score) } // Pass the option's actual score up + .padding(8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + // --- FIX 5: Use properties from the new FormOption data class --- + Text(text = option.displayText, modifier = Modifier.weight(1f)) + Text(text = "${option.score}") + } + } } } } } - -@Composable -fun QuestionCard(question: StrokeScaleQuestion, selectedOptions: MutableList) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 8.dp) - .background(Color.White) - .padding(16.dp), - horizontalAlignment = Alignment.Start - ) { - // Question Header - Text( - text = question.questionHeader, - fontWeight = FontWeight.Bold, - fontSize = 18.sp, - modifier = Modifier.padding(bottom = 4.dp) - ) - - // Subheader - if (!question.subHeader.isNullOrEmpty()) { - Text( - text = question.subHeader, - fontSize = 14.sp, - color = Color.Gray, - modifier = Modifier.padding(bottom = 8.dp) - ) - } - - // Options - question.options.forEachIndexed { index, option -> - Row( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 4.dp) - .background( - if (selectedOptions[question.id] == index) Color(0xFFA5D6A7) else Color(0xFFF3E5F5) - ) - .padding(8.dp) - .clickable { selectedOptions[question.id] = index }, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Text(text = option.title) - Text(text = if (option.score > 0) "+${option.score}" else "${option.score}") - } +// The factory for your ViewModel +class NewNIHFormViewModelFactory(private val neurologyRepository: SignalingClient) : + ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + if (modelClass.isAssignableFrom(NewNIHFormViewModel::class.java)) { + @Suppress("UNCHECKED_CAST") + return NewNIHFormViewModel(neurologyRepository) as T } + throw IllegalArgumentException("Unknown ViewModel class") } } - -data class StrokeScaleQuestion( - val id: Int, - val questionHeader: String, - val subHeader: String?, - val options: List diff --git a/app/src/main/java/com/example/neurology_project_android/FormManager.kt b/app/src/main/java/com/example/neurology_project_android/FormManager.kt index fbaf815..48f53aa 100644 --- a/app/src/main/java/com/example/neurology_project_android/FormManager.kt +++ b/app/src/main/java/com/example/neurology_project_android/FormManager.kt @@ -4,8 +4,10 @@ import android.util.Log import com.google.gson.Gson import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json import okhttp3.* import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.Request import org.json.JSONArray import org.json.JSONObject import java.io.IOException @@ -23,7 +25,7 @@ object FormManager { ) val request = Request.Builder() - .url("https://meechie.techkit.xyz:3016/key=peerjs/post") + .url("https://meechie.techkit.xyz:444/key=peerjs/post") .post(requestBody) .addHeader("Content-Type", "application/json") .addHeader("Action", "start_new_nihss_form") @@ -42,12 +44,54 @@ object FormManager { }) } + suspend fun loadForm(formId: String, client: OkHttpClient): NIHForm = withContext(Dispatchers.IO) { + Log.d(TAG, "formid is ${formId}") + + val json = JSONObject().apply { + put("form_id", formId) + } + + val requestBody = RequestBody.create( + "application/json; charset=utf-8".toMediaTypeOrNull(), + json.toString()) + val request = Request.Builder() + .url("https://meechie.techkit.xyz:444/key=peerjs/post") + .post(requestBody) + .addHeader("Content-Type", "application/json") + .addHeader("Action", "requestSingleForm") + .build() + + try { + // 🔑 Fix 2: Keep the synchronous execute() call, but it's now safe + // because it's wrapped in withContext(Dispatchers.IO). + try { + val response = client.newCall(request).execute() + if (response.isSuccessful) { + + val bodyString = response.body?.string().toString() + Log.d(TAG, "forms reponse ${bodyString}") + val formsList = decodeSingleForm(bodyString!!) + return@withContext formsList + } else { + Log.e("FORM_MANAGER", "Server error: ${response.code}") + } + } catch (e: Exception) { + Log.e("FORM_MANAGER", "Error opening form: ${e.message}") + } + } catch (e: Exception) { + Log.e("FORM_MANAGER", "Error opening form: ${e.message}") + } + + // Return null instead of an empty NIHForm() on failure + return@withContext NIHForm() + } + suspend fun fetchFormsForUser( username: String, client: OkHttpClient ): List = withContext(Dispatchers.IO) { val forms = mutableListOf() - + Log.d(TAG, "FETCHING FORMS FOR USER") val json = JSONObject().apply { put("username", username) } @@ -58,7 +102,7 @@ object FormManager { ) val request = Request.Builder() - .url("https://meechie.techkit.xyz:3016/key=peerjs/post") + .url("https://meechie.techkit.xyz:444/key=peerjs/post") .post(requestBody) .addHeader("Content-Type", "application/json") .addHeader("Action", "getUsersForms") @@ -67,22 +111,11 @@ object FormManager { try { val response = client.newCall(request).execute() if (response.isSuccessful) { + val bodyString = response.body?.string() - val jsonArray = JSONArray(bodyString) - - for (i in 0 until jsonArray.length()) { - val item = jsonArray.getJSONObject(i) -// forms.add( -// NIHForm( -// form_id = UUID.randomUUID(), -//// patientName = item.getString("patient_name"), -//// dob = item.getString("patient_dob"), -//// date = item.getString("form_date"), -//// formData = item.getString("results"), -// username = item.getString("username") -// ) -// ) - } + Log.d(TAG, "forms reponse ${bodyString}") + val formsList = decodeMultipleForms(bodyString!!) + return@withContext formsList } else { Log.e("FORM_MANAGER", "Server error: ${response.code}") } @@ -105,7 +138,7 @@ object FormManager { ) val request = Request.Builder() - .url("https://meechie.techkit.xyz:3016/key=peerjs/post") + .url("https://meechie.techkit.xyz:444/key=peerjs/post") .post(requestBody) .addHeader("Content-Type", "application/json") .addHeader("Action", "deleteForm") @@ -140,7 +173,7 @@ object FormManager { ) val request = Request.Builder() - .url("https://meechie.techkit.xyz:3016/key=peerjs/post") + .url("https://meechie.techkit.xyz:444/key=peerjs/post") .post(requestBody) .addHeader("Content-Type", "application/json") .addHeader("Action", "updateForm") @@ -165,4 +198,50 @@ object FormManager { } } } + + fun decodeMultipleForms(jsonString: String): List { + val jsonParser = Json { + // Essential for robustness: ignore fields present in JSON but not in your data class + ignoreUnknownKeys = true + // Handles cases where null is present for a property with a default value + coerceInputValues = true + } + Log.e(TAG, "Decoding forms") + return try { + // The key change: specify List as the target type + val formList: List = jsonParser.decodeFromString(jsonString) + formList + } catch (e: Exception) { + // Log the error and return an empty list or throw a custom exception + println("Error decoding list of forms: $e") + emptyList() + } + } + + fun decodeSingleForm(jsonString: String): NIHForm { + val jsonParser = Json { + ignoreUnknownKeys = true + coerceInputValues = true + } + + Log.e(TAG, "Attempting to decode single form from array.") + + return try { + // 1. Decode as a List because the JSON starts with '[' + val formList: List = jsonParser.decodeFromString(jsonString) + + // 2. Safely return the first element of the list + if (formList.isNotEmpty()) { + formList.first() + } else { + Log.e(TAG, "Successfully decoded array, but the list was empty.") + NIHForm() // Return a default empty form if the list is empty + } + + } catch (e: Exception) { + // This handles exceptions like malformed JSON or other parsing errors + Log.e(TAG, "Error decoding single form: ${e.message}") + NIHForm() // Return a default empty form on failure + } + } } diff --git a/app/src/main/java/com/example/neurology_project_android/ListNIHFormActivity.kt b/app/src/main/java/com/example/neurology_project_android/ListNIHFormActivity.kt index 5dea015..30c1b4e 100644 --- a/app/src/main/java/com/example/neurology_project_android/ListNIHFormActivity.kt +++ b/app/src/main/java/com/example/neurology_project_android/ListNIHFormActivity.kt @@ -89,14 +89,15 @@ fun ListNIHFormScreen(refreshTrigger: Int) { SavedFormItem( form = SavedForm(form.patientName!!, form.formDate!!), onClick = { - val intent = Intent(context, SavedNIHFormActivity::class.java).apply { -// putExtra("formId", form.id) + val intent = Intent(context, NewSavedNIHFormActivity::class.java).apply { + putExtra("form", form.form_id) // putExtra("patientName", form.patientName) // putExtra("dob", form.dob) // putExtra("date", form.date) // putExtra("formData", form.formData) // putExtra("username", form.username) } + context.startActivity(intent) } ) diff --git a/app/src/main/java/com/example/neurology_project_android/NIHForm.kt b/app/src/main/java/com/example/neurology_project_android/NIHForm.kt index 682b7d0..5212ebf 100644 --- a/app/src/main/java/com/example/neurology_project_android/NIHForm.kt +++ b/app/src/main/java/com/example/neurology_project_android/NIHForm.kt @@ -13,6 +13,8 @@ import java.util.UUID @Serializable data class NIHForm( + @SerialName(value = "form_id") + val form_id: String? = null, // Other string/text fields (all nullable) @SerialName("username") @@ -24,6 +26,9 @@ data class NIHForm( @SerialName("patient_name") val patientName: String? = null, + @SerialName("patient_dob") + val patientDob: String? = null, + @SerialName("sessionid") val sessionId: String? = null, diff --git a/app/src/main/java/com/example/neurology_project_android/NewNIHFormViewModel.kt b/app/src/main/java/com/example/neurology_project_android/NewNIHFormViewModel.kt index e1640b0..f68705c 100644 --- a/app/src/main/java/com/example/neurology_project_android/NewNIHFormViewModel.kt +++ b/app/src/main/java/com/example/neurology_project_android/NewNIHFormViewModel.kt @@ -144,9 +144,6 @@ class NewNIHFormViewModel @Inject constructor(private val client: SignalingClien } } -private fun SignalingClient.getUsername() { - TODO("Not yet implemented") -} /** * A sealed interface to represent the different states of the form submission process, diff --git a/app/src/main/java/com/example/neurology_project_android/NewSavedFormActivity.kt b/app/src/main/java/com/example/neurology_project_android/NewSavedFormActivity.kt new file mode 100644 index 0000000..f22fe49 --- /dev/null +++ b/app/src/main/java/com/example/neurology_project_android/NewSavedFormActivity.kt @@ -0,0 +1,263 @@ +package com.example.neurology_project_android + +import android.os.Build +import android.os.Bundle +import android.widget.Toast +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.viewModels +import androidx.annotation.RequiresApi +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +// --- FIX 1: Import the new models --- +import dagger.hilt.android.AndroidEntryPoint +import okhttp3.Dispatcher + +// --- You can remove the old StrokeScaleQuestions import if it exists --- + // This annotation tells Hilt to manage dependencies for this Activity +@AndroidEntryPoint +class NewSavedNIHFormActivity : ComponentActivity() { + + // 1. Get the ViewModel directly from Hilt. + // The `by viewModels()` delegate handles everything for you. + private val viewModel: SavedNIHFormViewModel by viewModels() + + @RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM) + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + // Hilt provides the ViewModel and its dependencies automatically. + // No manual setup needed. + val formId = intent.getStringExtra("form") + + setContent { + + NewNIHFormScreen(viewModel = viewModel, formId) + } + } + + // This is the Composable function your Activity is trying to call. + @RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM) + @Composable + fun NewNIHFormScreen(viewModel: SavedNIHFormViewModel, formId: String?) { + val context = LocalContext.current + val sessionManager = remember { SessionManager(context) } + val client = sessionManager.client + // 🔑 FIX: Use LaunchedEffect to manage the side effect (loading the form) + LaunchedEffect(key1 = formId, key2 = client) { + // This block runs when the Composable first enters the composition + // or if 'formId' or 'client' changes. + if (!formId.isNullOrBlank()) { + // Call the ViewModel function. + // This function is NOT suspend on the outside, but it launches + // a coroutine internally (viewModelScope.launch) to handle the async work. + viewModel.loadExistingForm(formId, client) + } + } + + + val patientName by viewModel.patientName + val itemScores by viewModel.itemScores + val submissionStatus by viewModel.submissionStatus.collectAsState() + + + + val totalScore by remember(itemScores) { + derivedStateOf { + itemScores.mapNotNull { it }.sum() + } + } + + // 2. React to changes in submissionStatus (e.g., show a toast, navigate away) + LaunchedEffect(submissionStatus) { + when (val status = submissionStatus) { + is SubmissionStatus.Success -> { + Toast.makeText(context, "Form submitted successfully!", Toast.LENGTH_SHORT).show() + // You could finish the activity upon success + // (context as? android.app.Activity)?.finish() + viewModel.resetSubmissionStatus() + } + is SubmissionStatus.Error -> { + Toast.makeText(context, status.message, Toast.LENGTH_LONG).show() + viewModel.resetSubmissionStatus() + } + else -> { /* Do nothing for Idle or Loading states here */ } + } + } + + // 3. Define the UI structure + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp) + .background(MaterialTheme.colorScheme.background) + ) { + Text( + text = "New NIH Stroke Scale Form", + fontSize = 24.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(bottom = 16.dp) + ) + + OutlinedTextField( + value = patientName, + // Send user input events up to the ViewModel + onValueChange = { viewModel.onPatientNameChange(it) }, + label = { Text("Patient Name") }, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(16.dp)) + + // A scrollable list for all the questions + LazyColumn(modifier = Modifier.weight(1f)) { + // --- FIX 2: Use the new NIHFormModel instead of StrokeScaleQuestions --- + itemsIndexed(NIHFormModel.questions) { index, question -> + QuestionCard( + question = question, + // Get the currently selected score for this question from the ViewModel's state + selectedScore = itemScores.getOrNull(index), + // When an option is clicked, notify the ViewModel with the question index and the option's score + onOptionClick = { score -> + viewModel.onScoreSelected(index, score) + } + ) + } + } + + // --- NEW: ADD THE TOTAL SCORE DISPLAY --- + Spacer(modifier = Modifier.height(16.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Total NIHSS Score:", + fontSize = 20.sp, + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.headlineSmall + ) + Text( + text = "$totalScore", // Display the calculated total score + fontSize = 22.sp, + fontWeight = FontWeight.ExtraBold, + color = MaterialTheme.colorScheme.primary + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + Button( + // When the button is clicked, call the submitForm function on the ViewModel + onClick = { }, + // Disable the button while the form is submitting + enabled = submissionStatus != SubmissionStatus.Loading, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp) + ) { + if (submissionStatus == SubmissionStatus.Loading) { + CircularProgressIndicator(modifier = Modifier.size(24.dp), color = Color.White) + } else { + Text("Save Form") + } + } + } + } + + /** + * A reusable Composable for displaying a single question, its options, and highlighting the selection. + */ + @Composable + fun QuestionCard(question: FormQuestion, selectedScore: Int?, onOptionClick: (Int) -> Unit) { // --- FIX 3: Use FormQuestion class --- + Card( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) + ) { + Column( + modifier = Modifier + .padding(16.dp) + ) { + // --- FIX 4: Use properties from the new FormQuestion data class --- + Text(text = question.questionText, fontWeight = FontWeight.Bold, fontSize = 18.sp) + if (question.instructionText.isNotEmpty()) { + Text( + text = question.instructionText, + fontSize = 14.sp, + color = Color.Gray, + modifier = Modifier.padding(bottom = 8.dp) + ) + } + + // Create a clickable row for each answer option + question.options.forEach { option -> + Row( + modifier = Modifier + .fillMaxWidth() + .background( + // Highlight the row if its score matches the selected score + if (selectedScore == option.score) MaterialTheme.colorScheme.primaryContainer else Color.Transparent + ) + .clickable { onOptionClick(option.score) } // Pass the option's actual score up + .padding(8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + // --- FIX 5: Use properties from the new FormOption data class --- + Text(text = option.displayText, modifier = Modifier.weight(1f)) + Text(text = "${option.score}") + } + } + } + } + } +} + +// The factory for your ViewModel +class SavedNIHFormViewModelFactory(private val neurologyRepository: SignalingClient) : + ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + if (modelClass.isAssignableFrom(SavedNIHFormViewModel::class.java)) { + @Suppress("UNCHECKED_CAST") + return SavedNIHFormViewModel(neurologyRepository) as T + } + throw IllegalArgumentException("Unknown ViewModel class") + } +} diff --git a/app/src/main/java/com/example/neurology_project_android/SavedNIHFormActivity.kt b/app/src/main/java/com/example/neurology_project_android/SavedNIHFormActivity.kt deleted file mode 100644 index 5c98913..0000000 --- a/app/src/main/java/com/example/neurology_project_android/SavedNIHFormActivity.kt +++ /dev/null @@ -1,216 +0,0 @@ -package com.example.neurology_project_android - -import android.os.Bundle -import android.widget.Toast -import androidx.activity.ComponentActivity -import androidx.activity.compose.setContent -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.material3.* -import androidx.compose.runtime.* -import androidx.compose.runtime.snapshots.SnapshotStateList -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import java.util.UUID - -class SavedNIHFormActivity : ComponentActivity() { - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - val form = NIHForm( - - ) - - setContent { - SavedNIHFormScreen(form) - } - } -} - -@Composable -fun SavedNIHFormScreen(form: NIHForm) { - val context = LocalContext.current - val sessionManager = remember { SessionManager(context) } - val questions = remember { NIHFormModel.questions } - val selectedOptions = remember { mutableStateListOf().apply { repeat(questions.size) { add(null) } } } - - var isEditing by remember { mutableStateOf(false) } - - LaunchedEffect(form) { -// val values = form.formData.map { c -> c.toString().toIntOrNull() ?: 9 } -// values.forEachIndexed { index, score -> -// selectedOptions[index] = if (score != 9) score else null -// } - } - - Column( - modifier = Modifier - .fillMaxSize() - .background(Color.White) - .padding(16.dp) - ) { - // Back Arrow - Row( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 8.dp), - horizontalArrangement = Arrangement.Start - ) { - Text( - text = "< Back", - fontSize = 20.sp, - fontWeight = FontWeight.Bold, - color = Color.Black, - modifier = Modifier - .clickable { (context as? ComponentActivity)?.finish() } - .padding(8.dp) - ) - } - - // Header - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text( - text = "NIH Stroke Scale Form", - fontSize = 24.sp, - fontWeight = FontWeight.Bold, - color = Color.Black - ) - - Text( - text = "Patient Name: ${form.patientName}", - fontSize = 18.sp, - fontWeight = FontWeight.Bold, - color = Color.Black, - modifier = Modifier.padding(top = 4.dp) - ) - - - Text( - text = "Date: ${form.formDate}", - fontSize = 16.sp, - color = Color.Gray - ) - } - - // Question list - LazyColumn(modifier = Modifier.weight(1f)) { - items(questions) { question -> - if (isEditing) { - QuestionCard(question, selectedOptions) - } else { - ReadOnlyQuestionCard(question, selectedOptions) - } - } - } - - // Buttons - Row( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 16.dp), - horizontalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterHorizontally) - ) { - Button( - onClick = { - if (isEditing) { - val updatedForm = NIHForm( - - ) - FormManager.updateForm(updatedForm, sessionManager.client) { success -> - (context as? ComponentActivity)?.runOnUiThread { - if (success) { - (context as? ComponentActivity)?.finish() - } else { - Toast.makeText(context, "Failed to update form", Toast.LENGTH_SHORT).show() - } - } - } - } else { - isEditing = true - isEditing = true - } - }, - colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFF3E5F5)), - modifier = Modifier.weight(1f) - ) { - Text(text = if (isEditing) "Done" else "Update", color = Color.Black) - } - - Button( - onClick = { -// FormManager.deleteForm(form.formId, form.username!!, sessionManager.client) { success -> -// (context as? ComponentActivity)?.runOnUiThread { -// if (success) { -// (context as? ComponentActivity)?.finish() -// } else { -// Toast.makeText(context, "Failed to delete form", Toast.LENGTH_SHORT).show() -// } -// } -// } - }, - colors = ButtonDefaults.buttonColors(containerColor = Color.Red), - modifier = Modifier.weight(1f) - ) { - Text(text = "Delete", color = Color.White) - } - } - } -} - -@Composable -fun QuestionCard(x0: FormQuestion, x1: SnapshotStateList) { - TODO("Not yet implemented") -} - -@Composable -fun ReadOnlyQuestionCard(question: FormQuestion, selectedOptions: List) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 8.dp) - .background(Color.White) - .padding(16.dp), - horizontalAlignment = Alignment.Start - ) { - Text( - text = question.questionText, - fontWeight = FontWeight.Bold, - fontSize = 18.sp, - modifier = Modifier.padding(bottom = 4.dp) - ) - - if (!question.instructionText.isNullOrEmpty()) { - Text( - text = question.instructionText, - fontSize = 14.sp, - color = Color.Gray, - modifier = Modifier.padding(bottom = 8.dp) - ) - } - - question.options.forEachIndexed { index, option -> - val isSelected = selectedOptions[question.id] == index - Row( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 4.dp) - .background(if (isSelected) Color(0xFFD1C4E9) else Color(0xFFF3E5F5)) - .padding(8.dp), - horizontalArrangement = Arrangement.SpaceBetween - ) { - Text(text = option.displayText, modifier = Modifier.weight(1f)) - Text(text = if (option.score > 0) "+${option.score}" else "${option.score}") - } - } - } -} diff --git a/app/src/main/java/com/example/neurology_project_android/SavedNIHFormViewModel.kt b/app/src/main/java/com/example/neurology_project_android/SavedNIHFormViewModel.kt new file mode 100644 index 0000000..66a6ef2 --- /dev/null +++ b/app/src/main/java/com/example/neurology_project_android/SavedNIHFormViewModel.kt @@ -0,0 +1,207 @@ +package com.example.neurology_project_android + +import android.os.Build +import android.util.Log +import androidx.annotation.RequiresApi +import androidx.compose.runtime.mutableStateOf +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.neurology_project_android.BuildConfig.API_POST_URL +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import okhttp3.OkHttpClient +import okhttp3.Request +import org.json.JSONObject +import javax.inject.Inject +@HiltViewModel +class SavedNIHFormViewModel @Inject constructor(private val client: SignalingClient) : ViewModel() { + + // --- UI State Management --- + + // Holds the patient's name, exposed to the View. + var patientName = mutableStateOf("") + private set // The View can read, but only the ViewModel can write to it. + + // Holds the selected option index for each of the 15 NIHSS items. + // The index in the list corresponds to the item number (e.g., index 0 is for item 1a). + // A null value means no option has been selected for that item. + var itemScores = mutableStateOf(List(15) { null }) + private set + + // Exposes the current status of the form submission process to the UI. + private val _submissionStatus = MutableStateFlow(SubmissionStatus.Idle) + val submissionStatus = _submissionStatus.asStateFlow() + + // --- User Events --- + @RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM) + suspend fun loadExistingForm(formId: String?, client: OkHttpClient){ + val postURL = API_POST_URL + val request = Request.Builder() + .url(postURL) + .addHeader("Content-Type", "application/json") + .addHeader("Action", "login") + .build() + // 2. Launch a coroutine using the ViewModel's scope + + // 3. Call the suspend function (FormManager.loadForm must be suspend!) + // Note: Since loadForm is a synchronous call wrapped in IO Dispatchers, + // you should ideally call it directly inside the coroutine. + + // Assuming FormManager.loadForm is correctly updated to be a suspend function: + val existingForm = FormManager.loadForm(formId!!, client) + + // 4. Update the state based on the result + if (existingForm != null) { + // Now call the function to map the NIHForm fields back to the state variables + mapFormToState(existingForm) + Log.d("NIHFormViewModel", "Successfully loaded form for ID: $formId") + } else { + Log.e("NIHFormViewModel", "Failed to load form for ID: $formId") + // Handle loading failure (e.g., set an error state) + } + + + + + //client.submitToServer("requestSingleForm", JSONObject().put("form_id", formId)) + } + + /** + * Maps the properties of a fully loaded NIHForm object back into the ViewModel's mutable state. + */ + private fun mapFormToState(existingForm: NIHForm) { + // This function should be called on the Main thread (which viewModelScope.launch defaults to). + + patientName.value = existingForm.patientName ?: "" + + val loadedScores = listOf( + existingForm.item1aLocLevel, + existingForm.item1bLocCommands, + existingForm.item1cLocBestGaze, + existingForm.item2BestMotorGaze, + existingForm.item3Visual, + existingForm.item4FacialPalsy, + existingForm.item5LeftArmMotor, + existingForm.item6RightArmMotor, + existingForm.item7LeftLegMotor, + existingForm.item8RightLegMotor, + existingForm.item9Ataxia, + existingForm.item10Sensory, + existingForm.item11Language, + existingForm.item12Dysarthria, + existingForm.item13ExtinctionInattention + ) + + if (loadedScores.size == 15) { + itemScores.value = loadedScores + } else { + Log.e("NIHFormViewModel", "Loaded scores list size is not 15, resetting scores.") + // You may choose to not reset and use the partial data, depending on requirements. + // itemScores.value = List(15) { null } + } + } + + /** + * Called by the View when the patient name TextField changes. + */ + fun onPatientNameChange(newName: String) { + patientName.value = newName + } + + /** + * Called by the View when the user selects an answer for a specific NIHSS item. + * @param itemIndex The index of the question (0-14). + * @param score The selected score for that item. + */ + fun onScoreSelected(itemIndex: Int, score: Int) { + // Create a new list with the updated score + val newScores = itemScores.value.toMutableList() + if (itemIndex in newScores.indices) { + newScores[itemIndex] = score + itemScores.value = newScores + } + } + + /** + * Called by the View when the "Save" button is clicked. + * It validates the input, builds the NIHForm object, and calls the repository to submit it. + */ + @RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM) + fun submitForm() { +// // 1. Validate Input +// if (patientName.value.isBlank()) { +// _submissionStatus.value = SubmissionStatus.Error("Patient name cannot be empty.") +// return +// } +// if (itemScores.value.any { it == null }) { +// _submissionStatus.value = SubmissionStatus.Error("All NIHSS items must be answered.") +// return +// } + + _submissionStatus.value = SubmissionStatus.Loading + + // 2. Build the NIHForm object using the current state + val scores = itemScores.value.mapNotNull { it } // Get a non-null list of scores + val formToSubmit = NIHForm( + + patientName = patientName.value, + // Map the scores from the list to the corresponding data class fields + item1aLocLevel = scores.getOrNull(0), + item1bLocCommands = scores.getOrNull(1), + item1cLocBestGaze = scores.getOrNull(2), + item2BestMotorGaze = scores.getOrNull(3), + item3Visual = scores.getOrNull(4), + item4FacialPalsy = scores.getOrNull(5), + item5LeftArmMotor = scores.getOrNull(6), + item6RightArmMotor = scores.getOrNull(7), + item7LeftLegMotor = scores.getOrNull(8), + item8RightLegMotor = scores.getOrNull(9), + item9Ataxia = scores.getOrNull(10), + item10Sensory = scores.getOrNull(11), + item11Language = scores.getOrNull(12), + item12Dysarthria = scores.getOrNull(13), + item13ExtinctionInattention = scores.getOrNull(14), + totalNihssScore = scores.sum() + // Add other fields like username or date if available + ) + val formJsonObject = JSONObject() + // 1. Manually .put() each field into the JSONObject. + // The keys are the strings you want in the final JSON, matching your @SerialName annotations. + formJsonObject.put("patient_name", patientName.value) + formJsonObject.put("item_1a_loc_level", scores.getOrNull(0)) + formJsonObject.put("item_1b_loc_commands", scores.getOrNull(1)) + formJsonObject.put("item_1c_loc_best_gaze", scores.getOrNull(2)) + formJsonObject.put("item_2_best_motor_gaze", scores.getOrNull(3)) + formJsonObject.put("item_3_visual", scores.getOrNull(4)) + formJsonObject.put("item_4_facial_palsy", scores.getOrNull(5)) + formJsonObject.put("item_5_left_arm_motor", scores.getOrNull(6)) + formJsonObject.put("item_6_right_arm_motor", scores.getOrNull(7)) + formJsonObject.put("item_7_left_leg_motor", scores.getOrNull(8)) + formJsonObject.put("item_8_right_leg_motor", scores.getOrNull(9)) + formJsonObject.put("item_9_ataxia", scores.getOrNull(10)) + formJsonObject.put("item_10_sensory", scores.getOrNull(11)) + formJsonObject.put("item_11_language", scores.getOrNull(12)) + formJsonObject.put("item_12_dysarthria", scores.getOrNull(13)) + formJsonObject.put("item_13_extinction_inattention", scores.getOrNull(14)) + formJsonObject.put("username", "david_android") + // Calculate and put the total score + //formJsonObject.put("total_nihss_score", scores.mapNotNull { it }.sum()) + + // 3. Launch a coroutine to call the repository + + } + + /** + * Resets the submission status, typically called after the UI has shown a message. + */ + fun resetSubmissionStatus() { + _submissionStatus.value = SubmissionStatus.Idle + } +} + +private fun SignalingClient.getUsername() { + TODO("Not yet implemented") +} + diff --git a/app/src/main/java/com/example/neurology_project_android/SignalingClient.kt b/app/src/main/java/com/example/neurology_project_android/SignalingClient.kt index 3e8b98f..7e58c49 100644 --- a/app/src/main/java/com/example/neurology_project_android/SignalingClient.kt +++ b/app/src/main/java/com/example/neurology_project_android/SignalingClient.kt @@ -136,11 +136,15 @@ class SignalingClient @OptIn(UnstableApi::class) constructor @OptIn(UnstableApi::class) - fun submitToServer(header:String, payload: JSONObject){ + fun submitToServer(header:String, payload: JSONObject): String { + var response = "blank" socket.emit(header, payload, Ack { args -> val responseData = args[0] Log.d(TAG, "Response from server: $responseData") + response = responseData.toString() }) + + return response } private var rooms: Array = emptyArray() @@ -218,7 +222,7 @@ class SignalingClient @OptIn(UnstableApi::class) constructor try { - socket = IO.socket("https://meechie.techkit.xyz:3016", options) + socket = IO.socket("https://meechie.techkit.xyz:444", options) socket.connect() // The Emitter.Listener callback runs on a background thread. socket.on("newProducers", Emitter.Listener { args -> From fa51b14b4918e0bc803bd3d1ac269c458045e308 Mon Sep 17 00:00:00 2001 From: David Date: Mon, 8 Dec 2025 19:36:24 -0500 Subject: [PATCH 09/14] - changes to ui in the call --- .../CallScreenActivity.kt | 194 +++++++++++++++--- .../ListNIHFormActivity.kt | 5 +- .../neurology_project_android/NIHFormModel.kt | 12 +- .../NewNIHFormViewModel.kt | 8 +- .../neurology_project_android/RoomClient.kt | 2 +- ...edFormActivity.kt => SavedFormActivity.kt} | 56 +++-- .../SavedNIHFormViewModel.kt | 2 +- .../SignalingClient.kt | 41 +++- .../SignalingRepository.kt | 5 +- .../main/res/drawable-xhdpi/call_end_24px.xml | 10 + .../main/res/drawable/assignment_add_24px.xml | 10 + app/src/main/res/drawable/mic_24px.xml | 10 + app/src/main/res/drawable/mic_off_24px.xml | 10 + 13 files changed, 296 insertions(+), 69 deletions(-) rename app/src/main/java/com/example/neurology_project_android/{NewSavedFormActivity.kt => SavedFormActivity.kt} (83%) create mode 100644 app/src/main/res/drawable-xhdpi/call_end_24px.xml create mode 100644 app/src/main/res/drawable/assignment_add_24px.xml create mode 100644 app/src/main/res/drawable/mic_24px.xml create mode 100644 app/src/main/res/drawable/mic_off_24px.xml diff --git a/app/src/main/java/com/example/neurology_project_android/CallScreenActivity.kt b/app/src/main/java/com/example/neurology_project_android/CallScreenActivity.kt index b913fd6..a42d38e 100644 --- a/app/src/main/java/com/example/neurology_project_android/CallScreenActivity.kt +++ b/app/src/main/java/com/example/neurology_project_android/CallScreenActivity.kt @@ -5,6 +5,7 @@ import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.* @@ -16,6 +17,9 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.res.painterResource import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.sp class CallScreenActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { @@ -50,54 +54,190 @@ fun CallScreen() { .padding(16.dp) .padding(bottom = 20.dp), horizontalArrangement = Arrangement.SpaceEvenly, - verticalAlignment = Alignment.CenterVertically - ) { - IconButton( - onClick = { + ) { + Column( + // Center the items horizontally within the column + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.weight(1f).background(Color.DarkGray).clickable(onClick = { val intent = Intent( context, ListNIHFormActivity::class.java ) - // 3. Start the new Activity + context.startActivity(intent) - /* Open Stroke Scale Form */ }, - modifier = Modifier - .size(64.dp) - .background(Color.Gray, shape = CircleShape) + /* Open Stroke Scale Form */ + }) + ) { - Icon( - painter = painterResource(id = android.R.drawable.ic_menu_edit), - contentDescription = null, - tint = Color.White + IconButton( + onClick = { + val intent = Intent( + context, + ListNIHFormActivity::class.java + ) + // 3. Start the new Activity + context.startActivity(intent) + /* Open Stroke Scale Form */ }, + modifier = Modifier + .size(64.dp) + .background(Color.DarkGray) + ) { + Icon( + painter = painterResource(R.drawable.assignment_add_24px), + contentDescription = null, + tint = Color.White + ) + } + // Add the Text composable right below the IconButton + Spacer(modifier = Modifier.height(4.dp)) // Optional: Add a small vertical space + Text( + text = "New Form/View Form", + modifier = Modifier.fillMaxWidth(), + + + fontSize = 16.sp, + + + fontWeight = androidx.compose.ui.text.font.FontWeight.Bold, + + + color = Color.White, + + textAlign = TextAlign.Center, + maxLines = 3, + overflow = TextOverflow.Ellipsis ) } - IconButton( - onClick = { isMuted = !isMuted }, - modifier = Modifier - .size(64.dp) - .background(Color.DarkGray, shape = CircleShape) + Column( + // Center the items horizontally within the column + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.weight(1f).background(Color.Gray).clickable(onClick = { + + }) + ) { - Icon( - painter = painterResource(id = if (isMuted) android.R.drawable.ic_lock_silent_mode else android.R.drawable.ic_lock_silent_mode_off), + IconButton( + onClick = { + if(isMuted){ + isMuted = false + } + else{ + isMuted = true + } + + }, + modifier = Modifier + .size(64.dp) + .background(Color.Gray) + ) { + Icon( + painter = painterResource(id = if (isMuted) R.drawable.mic_off_24px else R.drawable.mic_24px), contentDescription = null, tint = Color.White ) + } + // Add the Text composable right below the IconButton + Spacer(modifier = Modifier.height(4.dp)) // Optional: Add a small vertical space + Text( + text = "Mute/Unmute", + modifier = Modifier.fillMaxWidth(), + + + fontSize = 16.sp, + + + fontWeight = androidx.compose.ui.text.font.FontWeight.Bold, + + + color = Color.White, + + textAlign = TextAlign.Center, + maxLines = 3, + overflow = TextOverflow.Ellipsis + ) } - IconButton( - onClick = { /* End Call */ }, - modifier = Modifier - .size(64.dp) - .background(Color.Red, shape = CircleShape) + Column( + // Center the items horizontally within the column + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.weight(1f).background(Color.Red).clickable(onClick = { + val intent = Intent( + context, + ListNIHFormActivity::class.java + ) + + context.startActivity(intent) + /* Open Stroke Scale Form */ + }) + ) { - Icon( - painter = painterResource(id = android.R.drawable.ic_menu_close_clear_cancel), + IconButton( + onClick = { + val intent = Intent( + context, + ListNIHFormActivity::class.java + ) + // 3. Start the new Activity + context.startActivity(intent) + /* Open Stroke Scale Form */ }, + modifier = Modifier + .size(64.dp) + .background(Color.Red) + ) { + Icon( + painter = painterResource(R.drawable.call_end_24px), contentDescription = null, tint = Color.White ) + } + // Add the Text composable right below the IconButton + Spacer(modifier = Modifier.height(4.dp)) // Optional: Add a small vertical space + Text( + text = "End Call", + modifier = Modifier.fillMaxWidth(), + + + fontSize = 16.sp, + + + fontWeight = androidx.compose.ui.text.font.FontWeight.Bold, + + + color = Color.White, + + textAlign = TextAlign.Center, + maxLines = 3, + overflow = TextOverflow.Ellipsis + ) } + +// IconButton( +// onClick = { isMuted = !isMuted }, +// modifier = Modifier +// .size(64.dp) +// .background(Color.DarkGray, shape = CircleShape) +// ) { +// Icon( +// painter = painterResource(id = if (isMuted) R.drawable.mic_off_24px else R.drawable.mic_24px), +// contentDescription = null, +// tint = Color.White +// ) +// } +// +// IconButton( +// onClick = { /* End Call */ }, +// modifier = Modifier +// .size(64.dp) +// .background(Color.Red, shape = CircleShape) +// ) { +// Icon( +// painter = painterResource(R.drawable.call_end_24px), +// contentDescription = null, +// tint = Color.White +// ) +// } } } } diff --git a/app/src/main/java/com/example/neurology_project_android/ListNIHFormActivity.kt b/app/src/main/java/com/example/neurology_project_android/ListNIHFormActivity.kt index 30c1b4e..aa7d4bd 100644 --- a/app/src/main/java/com/example/neurology_project_android/ListNIHFormActivity.kt +++ b/app/src/main/java/com/example/neurology_project_android/ListNIHFormActivity.kt @@ -87,7 +87,10 @@ fun ListNIHFormScreen(refreshTrigger: Int) { ) { items(savedForms) { form -> SavedFormItem( - form = SavedForm(form.patientName!!, form.formDate!!), + form = SavedForm( + patientName = form.patientName?.takeIf { it.isNotBlank() } ?: "Unnamed Patient", + dateRecorded = form.formDate ?: "No Date" // Also handle null date for safety + ), onClick = { val intent = Intent(context, NewSavedNIHFormActivity::class.java).apply { putExtra("form", form.form_id) diff --git a/app/src/main/java/com/example/neurology_project_android/NIHFormModel.kt b/app/src/main/java/com/example/neurology_project_android/NIHFormModel.kt index ec8733f..cbf84e8 100644 --- a/app/src/main/java/com/example/neurology_project_android/NIHFormModel.kt +++ b/app/src/main/java/com/example/neurology_project_android/NIHFormModel.kt @@ -92,7 +92,7 @@ object NIHFormModel { FormOption("Some effort against gravity; arm cannot get to or maintain (if cued) 90 (or 45) degrees, drifts down to bed.", 2), FormOption("No effort against gravity; arm falls.", 3), FormOption("No movement.", 4), - FormOption("Amputation or joint fusion, explain:", 9) + FormOption("Amputation or joint fusion, explain:", 0) ) ), FormQuestion( @@ -105,7 +105,7 @@ object NIHFormModel { FormOption("Some effort against gravity; arm cannot get to or maintain (if cued) 90 (or 45) degrees, drifts down to bed.", 2), FormOption("No effort against gravity; arm falls.", 3), FormOption("No movement.", 4), - FormOption("Amputation or joint fusion, explain:", 9) + FormOption("Amputation or joint fusion, explain:", 0) ) ), FormQuestion( @@ -118,7 +118,7 @@ object NIHFormModel { FormOption("Some effort against gravity; leg falls to bed by 5 seconds, but has some effort against gravity.", 2), FormOption("No effort against gravity; leg falls to bed immediately.", 3), FormOption("No movement.", 4), - FormOption("Amputation or joint fusion, explain:", 9) + FormOption("Amputation or joint fusion, explain:", 0) ) ), FormQuestion( @@ -131,7 +131,7 @@ object NIHFormModel { FormOption("Some effort against gravity; leg falls to bed by 5 seconds, but has some effort against gravity.", 2), FormOption("No effort against gravity; leg falls to bed immediately.", 3), FormOption("No movement.", 4), - FormOption("Amputation or joint fusion, explain:", 9) + FormOption("Amputation or joint fusion, explain:", 0) ) ), FormQuestion( @@ -142,7 +142,7 @@ object NIHFormModel { FormOption("Absent.", 0), FormOption("Present in one limb.", 1), FormOption("Present in two limbs.", 2), - FormOption("Does not understand or is paralyzed.", 9) + FormOption("Does not understand or is paralyzed.", 0) ) ), FormQuestion( @@ -174,7 +174,7 @@ object NIHFormModel { FormOption("Normal.", 0), FormOption("Mild-to-moderate dysarthria; slurring of words but can be understood.", 1), FormOption("Severe dysarthria; so slurred it is unintelligible or worse.", 2), - FormOption("Intubated or other physical barrier.", 9) + FormOption("Intubated or other physical barrier.", 0) ) ), FormQuestion( diff --git a/app/src/main/java/com/example/neurology_project_android/NewNIHFormViewModel.kt b/app/src/main/java/com/example/neurology_project_android/NewNIHFormViewModel.kt index f68705c..4a80d6d 100644 --- a/app/src/main/java/com/example/neurology_project_android/NewNIHFormViewModel.kt +++ b/app/src/main/java/com/example/neurology_project_android/NewNIHFormViewModel.kt @@ -117,7 +117,7 @@ class NewNIHFormViewModel @Inject constructor(private val client: SignalingClien formJsonObject.put("item_11_language", scores.getOrNull(12)) formJsonObject.put("item_12_dysarthria", scores.getOrNull(13)) formJsonObject.put("item_13_extinction_inattention", scores.getOrNull(14)) - formJsonObject.put("username", "david_android") + formJsonObject.put("username", "thera") // Calculate and put the total score //formJsonObject.put("total_nihss_score", scores.mapNotNull { it }.sum()) @@ -127,8 +127,10 @@ class NewNIHFormViewModel @Inject constructor(private val client: SignalingClien var jsonString = formToSubmit.toJson() Log.d("NEWNIHFORMVIEWMODEL", "JSON: $jsonString") var stringToSubmit = JSONObject().put("payload", formJsonObject) - val success = client.submitToServer("CREATEFORM", stringToSubmit) - //_submissionStatus.value = if (success) SubmissionStatus.Success else SubmissionStatus.Error("Failed to submit form to server.") + var success = client.submitToServer("CREATEFORM", stringToSubmit) + Log.d("NEWNIHFORMVIEWMODEL", "SUCCESS: $success") + _submissionStatus.value = if (success) SubmissionStatus.Success else SubmissionStatus.Error("Failed to submit form to server.") + } catch (e: Exception) { _submissionStatus.value = SubmissionStatus.Error("An error occurred: ${e.message}") Log.e("Error", "An error occurred: ${e.message}") diff --git a/app/src/main/java/com/example/neurology_project_android/RoomClient.kt b/app/src/main/java/com/example/neurology_project_android/RoomClient.kt index 22ea3c9..ada14cf 100644 --- a/app/src/main/java/com/example/neurology_project_android/RoomClient.kt +++ b/app/src/main/java/com/example/neurology_project_android/RoomClient.kt @@ -508,7 +508,7 @@ class RoomClient constructor(room_id: String, name: String, socket: Socket, cont - joinRoom(room_id, "david_android") + joinRoom(room_id, "thera") } diff --git a/app/src/main/java/com/example/neurology_project_android/NewSavedFormActivity.kt b/app/src/main/java/com/example/neurology_project_android/SavedFormActivity.kt similarity index 83% rename from app/src/main/java/com/example/neurology_project_android/NewSavedFormActivity.kt rename to app/src/main/java/com/example/neurology_project_android/SavedFormActivity.kt index f22fe49..934153b 100644 --- a/app/src/main/java/com/example/neurology_project_android/NewSavedFormActivity.kt +++ b/app/src/main/java/com/example/neurology_project_android/SavedFormActivity.kt @@ -21,6 +21,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator @@ -44,7 +45,6 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider // --- FIX 1: Import the new models --- import dagger.hilt.android.AndroidEntryPoint -import okhttp3.Dispatcher // --- You can remove the old StrokeScaleQuestions import if it exists --- // This annotation tells Hilt to manage dependencies for this Activity @@ -87,7 +87,7 @@ class NewSavedNIHFormActivity : ComponentActivity() { viewModel.loadExistingForm(formId, client) } } - + var isEditable = false val patientName by viewModel.patientName val itemScores by viewModel.itemScores @@ -153,7 +153,8 @@ class NewSavedNIHFormActivity : ComponentActivity() { // When an option is clicked, notify the ViewModel with the question index and the option's score onOptionClick = { score -> viewModel.onScoreSelected(index, score) - } + }, + isEditable = isEditable, ) } } @@ -182,19 +183,39 @@ class NewSavedNIHFormActivity : ComponentActivity() { Spacer(modifier = Modifier.height(8.dp)) - Button( - // When the button is clicked, call the submitForm function on the ViewModel - onClick = { }, - // Disable the button while the form is submitting - enabled = submissionStatus != SubmissionStatus.Loading, + // Wrap the buttons in a Row + Row( modifier = Modifier - .fillMaxWidth() - .padding(vertical = 8.dp) + .fillMaxWidth() // Row takes up the full width + .padding(vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp) // Add spacing between the buttons ) { - if (submissionStatus == SubmissionStatus.Loading) { - CircularProgressIndicator(modifier = Modifier.size(24.dp), color = Color.White) - } else { - Text("Save Form") + // 1. Update/Save Button + Button( + onClick = { isEditable = true }, + enabled = submissionStatus != SubmissionStatus.Loading, + modifier = Modifier.weight(1f) // 🔑 Takes up half the row space + ) { + if (submissionStatus == SubmissionStatus.Loading) { + CircularProgressIndicator(modifier = Modifier.size(24.dp), color = Color.White) + } else { + // Changed text to suggest updating + Text("Update Form") + } + } + + // 2. Delete Button + Button( + onClick = { /* TODO: Call viewModel.deleteForm() */ }, + enabled = submissionStatus != SubmissionStatus.Loading, + colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error), // Use error color for deletion + modifier = Modifier.weight(1f) // 🔑 Takes up the other half of the row space + ) { + if (submissionStatus == SubmissionStatus.Loading) { + CircularProgressIndicator(modifier = Modifier.size(24.dp), color = Color.White) + } else { + Text("Delete Form") + } } } } @@ -204,7 +225,7 @@ class NewSavedNIHFormActivity : ComponentActivity() { * A reusable Composable for displaying a single question, its options, and highlighting the selection. */ @Composable - fun QuestionCard(question: FormQuestion, selectedScore: Int?, onOptionClick: (Int) -> Unit) { // --- FIX 3: Use FormQuestion class --- + fun QuestionCard(question: FormQuestion, selectedScore: Int?, onOptionClick: (Int) -> Unit, isEditable: Boolean) { // --- FIX 3: Use FormQuestion class --- Card( modifier = Modifier .fillMaxWidth() @@ -235,8 +256,11 @@ class NewSavedNIHFormActivity : ComponentActivity() { // Highlight the row if its score matches the selected score if (selectedScore == option.score) MaterialTheme.colorScheme.primaryContainer else Color.Transparent ) - .clickable { onOptionClick(option.score) } // Pass the option's actual score up + .clickable(enabled = isEditable) { + onOptionClick(option.score) + } .padding(8.dp), + horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { diff --git a/app/src/main/java/com/example/neurology_project_android/SavedNIHFormViewModel.kt b/app/src/main/java/com/example/neurology_project_android/SavedNIHFormViewModel.kt index 66a6ef2..787b1d8 100644 --- a/app/src/main/java/com/example/neurology_project_android/SavedNIHFormViewModel.kt +++ b/app/src/main/java/com/example/neurology_project_android/SavedNIHFormViewModel.kt @@ -185,7 +185,7 @@ class SavedNIHFormViewModel @Inject constructor(private val client: SignalingCli formJsonObject.put("item_11_language", scores.getOrNull(12)) formJsonObject.put("item_12_dysarthria", scores.getOrNull(13)) formJsonObject.put("item_13_extinction_inattention", scores.getOrNull(14)) - formJsonObject.put("username", "david_android") + formJsonObject.put("username", "thera") // Calculate and put the total score //formJsonObject.put("total_nihss_score", scores.mapNotNull { it }.sum()) diff --git a/app/src/main/java/com/example/neurology_project_android/SignalingClient.kt b/app/src/main/java/com/example/neurology_project_android/SignalingClient.kt index 7e58c49..3116722 100644 --- a/app/src/main/java/com/example/neurology_project_android/SignalingClient.kt +++ b/app/src/main/java/com/example/neurology_project_android/SignalingClient.kt @@ -37,6 +37,8 @@ import org.json.JSONArray import org.json.JSONObject import kotlin.concurrent.fixedRateTimer import kotlin.contracts.contract +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine //import org.webrtc.Camera2Capturer //import org.webrtc.CameraVideoCapturer.CameraEventsHandler @@ -131,20 +133,39 @@ class SignalingClient @OptIn(UnstableApi::class) constructor } fun joinRoom(room_id: String) { - currentRoomClient = RoomClient(room_id, "david_android", socket, context = this.context) + currentRoomClient = RoomClient(room_id, "thera", socket, context = this.context) } - @OptIn(UnstableApi::class) - fun submitToServer(header:String, payload: JSONObject): String { - var response = "blank" - socket.emit(header, payload, Ack { args -> - val responseData = args[0] - Log.d(TAG, "Response from server: $responseData") - response = responseData.toString() - }) - return response + @OptIn(UnstableApi::class) + suspend fun submitToServer(header: String, payload: JSONObject): Boolean { + // Use suspendCoroutine to bridge the callback to a coroutine + return suspendCoroutine { continuation -> + socket.emit(header, payload, Ack { args -> + try { + if (args.isNotEmpty() && args[0] != null) { + val responseJson = args[0] as JSONObject + Log.d(TAG, "Response from server: $responseJson") + val status = responseJson.optString("response", "") + + if (status == "SUCCESS") { + Log.d(TAG, "Server response was SUCCESS") + continuation.resume(true) // <-- Resume the coroutine with 'true' + } else { + Log.w(TAG, "Server response was not SUCCESS: $status") + continuation.resume(false) // <-- Resume the coroutine with 'false' + } + } else { + Log.e(TAG, "Received empty or null response from server for header: $header") + continuation.resume(false) // <-- Resume with 'false' on empty response + } + } catch (e: Exception) { + Log.e(TAG, "Error parsing server response", e) + continuation.resume(false) // <-- Resume with 'false' on error + } + }) + } } private var rooms: Array = emptyArray() diff --git a/app/src/main/java/com/example/neurology_project_android/SignalingRepository.kt b/app/src/main/java/com/example/neurology_project_android/SignalingRepository.kt index 7ff5c24..b37868e 100644 --- a/app/src/main/java/com/example/neurology_project_android/SignalingRepository.kt +++ b/app/src/main/java/com/example/neurology_project_android/SignalingRepository.kt @@ -5,8 +5,5 @@ import androidx.annotation.RequiresApi import org.json.JSONObject class SignalingRepository(private val client: SignalingClient) { - @RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM) - fun submitToServer(header: String, data: JSONObject){ - client.submitToServer(header, data) - } + } \ No newline at end of file diff --git a/app/src/main/res/drawable-xhdpi/call_end_24px.xml b/app/src/main/res/drawable-xhdpi/call_end_24px.xml new file mode 100644 index 0000000..d1952da --- /dev/null +++ b/app/src/main/res/drawable-xhdpi/call_end_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/assignment_add_24px.xml b/app/src/main/res/drawable/assignment_add_24px.xml new file mode 100644 index 0000000..bfd07a3 --- /dev/null +++ b/app/src/main/res/drawable/assignment_add_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/mic_24px.xml b/app/src/main/res/drawable/mic_24px.xml new file mode 100644 index 0000000..b13baa4 --- /dev/null +++ b/app/src/main/res/drawable/mic_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/mic_off_24px.xml b/app/src/main/res/drawable/mic_off_24px.xml new file mode 100644 index 0000000..4b7c3ce --- /dev/null +++ b/app/src/main/res/drawable/mic_off_24px.xml @@ -0,0 +1,10 @@ + + + From 7825ab0c2ff302a174612d25dc0d58dab34bcb4c Mon Sep 17 00:00:00 2001 From: David Date: Mon, 8 Dec 2025 19:36:57 -0500 Subject: [PATCH 10/14] changes to fix some bugs and ui issues --- app/src/main/java/com/example/.DS_Store | Bin 6148 -> 8196 bytes .../interfaceExample/BadController.java | 9 ++++ .../interfaceExample/ElectricCar.java | 46 ++++++++++++++++++ .../interfaceExample/GoodController.java | 10 ++++ .../sampledata/interfaceExample/Movable.java | 10 ++++ .../interfaceExample/BadController.class | Bin 0 -> 763 bytes .../sampledata/interfaceExample/Car.class | Bin 0 -> 2214 bytes .../interfaceExample/Drivable.class | Bin 0 -> 225 bytes .../interfaceExample/ElectricCar.class | Bin 0 -> 2314 bytes .../sampledata/interfaceExample/Example.class | Bin 0 -> 2033 bytes .../interfaceExample/GoodController.class | Bin 0 -> 948 bytes .../sampledata/interfaceExample/Movable.class | Bin 0 -> 212 bytes .../sampledata/interfaceExample/Person.class | Bin 0 -> 1495 bytes .../sampledata/interfaceExample/Vehicle.class | Bin 0 -> 1616 bytes 14 files changed, 75 insertions(+) create mode 100644 app/src/main/java/com/example/neurology_project_android/sampledata/interfaceExample/BadController.java create mode 100644 app/src/main/java/com/example/neurology_project_android/sampledata/interfaceExample/ElectricCar.java create mode 100644 app/src/main/java/com/example/neurology_project_android/sampledata/interfaceExample/GoodController.java create mode 100644 app/src/main/java/com/example/neurology_project_android/sampledata/interfaceExample/Movable.java create mode 100644 app/src/main/java/com/example/neurology_project_android/sampledata/interfaceExample/out/production/interfaceExample/com/example/neurology_project_android/sampledata/interfaceExample/BadController.class create mode 100644 app/src/main/java/com/example/neurology_project_android/sampledata/interfaceExample/out/production/interfaceExample/com/example/neurology_project_android/sampledata/interfaceExample/Car.class create mode 100644 app/src/main/java/com/example/neurology_project_android/sampledata/interfaceExample/out/production/interfaceExample/com/example/neurology_project_android/sampledata/interfaceExample/Drivable.class create mode 100644 app/src/main/java/com/example/neurology_project_android/sampledata/interfaceExample/out/production/interfaceExample/com/example/neurology_project_android/sampledata/interfaceExample/ElectricCar.class create mode 100644 app/src/main/java/com/example/neurology_project_android/sampledata/interfaceExample/out/production/interfaceExample/com/example/neurology_project_android/sampledata/interfaceExample/Example.class create mode 100644 app/src/main/java/com/example/neurology_project_android/sampledata/interfaceExample/out/production/interfaceExample/com/example/neurology_project_android/sampledata/interfaceExample/GoodController.class create mode 100644 app/src/main/java/com/example/neurology_project_android/sampledata/interfaceExample/out/production/interfaceExample/com/example/neurology_project_android/sampledata/interfaceExample/Movable.class create mode 100644 app/src/main/java/com/example/neurology_project_android/sampledata/interfaceExample/out/production/interfaceExample/com/example/neurology_project_android/sampledata/interfaceExample/Person.class create mode 100644 app/src/main/java/com/example/neurology_project_android/sampledata/interfaceExample/out/production/interfaceExample/com/example/neurology_project_android/sampledata/interfaceExample/Vehicle.class diff --git a/app/src/main/java/com/example/.DS_Store b/app/src/main/java/com/example/.DS_Store index 5242d058aaea508acc9723ee7501a48ed4f3139f..fb6c37c5f9c061fea0b1949fe1f997e16d50e3e6 100644 GIT binary patch literal 8196 zcmeHMYitx%6u#fIz>FPWS_>3pvRlh5SnRe`dT2!1sqZbxxBZ9TPOzI%o(=0HVAE z2pU~e9T4cmLz)cfn4p0w*%Z|SLREw%2824T$GJGsWJt#Z73vH^ogut4!U_e!-AOMV zh%+Pwjm9VfQ3NiJ0Ef>)<}j1xS#DDOJ{0f=X(h{is^`-rWdw5;Gcv>8P{wvMqUaw_|bI@blte7adcFX z=hf70*wc5&9(3HNgtG`Y3^sd;^G{!!=eAd97M_=+ik_b?dam45rq0$oySjVS-YzTa zIw|Xj*{99*$o(C}?#`J7a=SC@+WA4p>(k1!X*-*@)4sXcwz)9P&GoW5*S2@Np5 zoCUEZD^@k!c~^4Fj<)+I=4#~?YNa~AvtW5v#x^^KEyEw^PJ3C?F)XLQhu(qXTL&yN zr<9{sX?N0t=0ru+!bP_%*7d2!!QkUdQsKF3O%KF$l7=Q4fQ{IMooGh~_9BBru;IXkk5N2?F+7aN z@GPFg^LPQT;5EFCH}Mw6@d1wGLwtrW@Fl*+H#mb|aTdSf94_D@{*=n4MbZ*!skBV0 zlM+&cv|8FKZIgCO-BL#Cmj;9fQW2EG`gEF>l`;L3m)*Bv_ z-_PMNyt#7T`~~r)E9)B9Zrpq+Sw(Ia)A{c(36L=fPXRK5?3tR?0y`o({I! zEDmvbf=gH5s>ZY#1TMiYTTbju0+wKJtBu7piSR7gM14%vN(ik=pnFFxu?U%U>s3`N zCESX&O**kGWSW}E3`e1Sz47oZJI#J%XW2P+9y3t}4Kb|1?P#R@--_*ofmXB;2D;FL zedvXWepon2i11OsVT|Ai9>Jq{98cg$!o!Pr2`}RqUL{PtL8utV+js}>;v<~ECpd{u z19VK_2mFMer{sIvdSD(Bd)(@)w(BuzGaRa}XXs^azkmg&F$UzM#$D2hN7 zf&XR%P}-7eX`*MJZZF}rcAWYH)Oq7_V}b@MG;k3+lHayZiOc0|$rZa4g=b6I{04A6d6951J diff --git a/app/src/main/java/com/example/neurology_project_android/sampledata/interfaceExample/BadController.java b/app/src/main/java/com/example/neurology_project_android/sampledata/interfaceExample/BadController.java new file mode 100644 index 0000000..c445217 --- /dev/null +++ b/app/src/main/java/com/example/neurology_project_android/sampledata/interfaceExample/BadController.java @@ -0,0 +1,9 @@ +package com.example.neurology_project_android.sampledata.interfaceExample; + +public class BadController { + public void executeMove() { + // Direct dependency on the low-level Car class. 🚨 + Car myCar = new Car("Honda", "Civic", 4); + myCar.move(10, 20); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/neurology_project_android/sampledata/interfaceExample/ElectricCar.java b/app/src/main/java/com/example/neurology_project_android/sampledata/interfaceExample/ElectricCar.java new file mode 100644 index 0000000..3aaa090 --- /dev/null +++ b/app/src/main/java/com/example/neurology_project_android/sampledata/interfaceExample/ElectricCar.java @@ -0,0 +1,46 @@ +package com.example.neurology_project_android.sampledata.interfaceExample; + +public class ElectricCar extends Car implements Movable, Drivable { + + private int chargeState; + + public ElectricCar(String make, String model, int doors) { + // MUST call the parent's constructor first + super(make, model, doors); + chargeState = 100; + } + + // 1. Fulfills Vehicle's abstract contract + @Override + public void accelerate(int rate) { + // Car's specific acceleration logic + System.out.println(getModel() + " is accelerating with 4 tires."); + // Code to increase currentSpeed would go here... + } + + // 2. Fulfills Movable's interface contract (from your previous example) + @Override + public void move(int x, int y) { + chargeState = chargeState - 10; + // Car's specific movement logic + System.out.println(getModel() + " moved to new coordinates. (" + x + ", " + y + ")"); + System.out.println(getModel() + "charge dropped to " + chargeState); + } + + @Override + public void getPosition() { + // Implementation for getting position + } + + // Fulfills the contract from the Drivable interface + @Override + public void drive() { + System.out.println(getModel() + " is being **driven** on the road by a human."); + } + + // Fulfills the second contract from the Drivable interface + @Override + public void steer(String direction) { + System.out.println(getModel() + " steering wheel turned " + direction + "."); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/neurology_project_android/sampledata/interfaceExample/GoodController.java b/app/src/main/java/com/example/neurology_project_android/sampledata/interfaceExample/GoodController.java new file mode 100644 index 0000000..bb0ce1e --- /dev/null +++ b/app/src/main/java/com/example/neurology_project_android/sampledata/interfaceExample/GoodController.java @@ -0,0 +1,10 @@ +package com.example.neurology_project_android.sampledata.interfaceExample; + +public class GoodController { + // High-level module depends on the Movable abstraction. + //This allows use to use GoodController on either the car or person!! + public void moveItem(Movable item) { + System.out.println("Controller received an item to move..."); + item.move(50, 100); + } +} diff --git a/app/src/main/java/com/example/neurology_project_android/sampledata/interfaceExample/Movable.java b/app/src/main/java/com/example/neurology_project_android/sampledata/interfaceExample/Movable.java new file mode 100644 index 0000000..09c2327 --- /dev/null +++ b/app/src/main/java/com/example/neurology_project_android/sampledata/interfaceExample/Movable.java @@ -0,0 +1,10 @@ +package com.example.neurology_project_android.sampledata.interfaceExample; + +public interface Movable { + // This method signature is the contract. + // Any class implementing Movable MUST define this method. + void move(int x, int y); + + // Another method for the contract + void getPosition(); +} \ No newline at end of file diff --git a/app/src/main/java/com/example/neurology_project_android/sampledata/interfaceExample/out/production/interfaceExample/com/example/neurology_project_android/sampledata/interfaceExample/BadController.class b/app/src/main/java/com/example/neurology_project_android/sampledata/interfaceExample/out/production/interfaceExample/com/example/neurology_project_android/sampledata/interfaceExample/BadController.class new file mode 100644 index 0000000000000000000000000000000000000000..8efdd4b665d7a42d5c9a4376dd96132ec986d731 GIT binary patch literal 763 zcmb`F*-8U36o&tFn>x0(R;~MXR~4KGsCZ#i} zw$^lzXV?vN?1={+-$lYxqGNQV+r6thqix0&SE11|^zQosAy2p`RU*ta55(>;tHDhk zE<^S}tB~hWpx=h<%D{z#G22Xup;q(1+c{5+RPFk2%i&tnCXE+R!30Al)?Gog+95d{ zOfj7Nz1@Tkd+wEEDPNp9pl!>Rwz zebgC7#e)bsi8va}9QSCi4Ez63i9p16&vnNHVo&ym`>};hdo@_aBt0Per9ewZj%1ZI zk4{EscKsRbvG1NHY282?Gb9Ux0L)^JG=}-FO7<9Z@I>;PSix|NI7KzTVM6oF>e*%h$#S#Dj literal 0 HcmV?d00001 diff --git a/app/src/main/java/com/example/neurology_project_android/sampledata/interfaceExample/out/production/interfaceExample/com/example/neurology_project_android/sampledata/interfaceExample/Car.class b/app/src/main/java/com/example/neurology_project_android/sampledata/interfaceExample/out/production/interfaceExample/com/example/neurology_project_android/sampledata/interfaceExample/Car.class new file mode 100644 index 0000000000000000000000000000000000000000..4a27f01164f9218b2dedff11e2b501d9e429be57 GIT binary patch literal 2214 zcmb_e>rNX-6#mA>tT9e74!J=t(*%fZ2<|0q(~{CQ27*%)XrWH4R*Ht*3Er^Yv3AEK zev`gND>aq)(Ff>5RXMY}GLBgF50&NFnVoaK^PQ{x&%Y;s19*&|3&el5O(d~J%?#q6lJassD(F@coY3molcLL=)^)M1}gcfzz*D`o1Y`KwWwPnY(J;^Zs z$n{;l#&ECFdL@oU-4p&{eNP9jf4F+~O|?CNJSHp@ikL)^VdMN<8X{o0;P<-+GT3>! zu2c{*u;v6VV#-3Ph-s7=N+awBp_W~StmKUq@PwdlB+hs7^F^ujqR8l0G%@oUJs*-DEWU**8?=d_~(LC#Y3X+BD z|59b!>R23j(!vtM)AJX~df-NQ=`lI$S+m+^7;8}G8KzpUFP}vT;R>&?WzMR3JY~2afgMPLQmaKA{aTGHpX-j~ff5cs7;wQm{jTr{y2Yp+ zdE`T6xFe;f8^%e3C7)2}o| z#^_nBEiL~AR=fEptl#OJ!3wPx=@uxE&Sa!rr&BGK$A|ccPNa#C@d=@s-2>WXqTQ!- z%F^4<@Offsi*C$a=?`3}y~U*yTm{}@?$nz3@dL`3!VHY1E3y30QV9V-(ipL3v{=SHh#kOcdn8pH2?qr literal 0 HcmV?d00001 diff --git a/app/src/main/java/com/example/neurology_project_android/sampledata/interfaceExample/out/production/interfaceExample/com/example/neurology_project_android/sampledata/interfaceExample/Drivable.class b/app/src/main/java/com/example/neurology_project_android/sampledata/interfaceExample/out/production/interfaceExample/com/example/neurology_project_android/sampledata/interfaceExample/Drivable.class new file mode 100644 index 0000000000000000000000000000000000000000..108e9c648e5d1bb3197293c6a5583a4ea26f348f GIT binary patch literal 225 zcmYL@%MJlC6o&u0jY}jp-hquifSq6=5(|l~oS8W$l}p!~nPMKmF|J|)UyNs7g2TT8# cJ&9~fAf$xxKrK`?(L$_D&<=_~C+g_F0ToL;Y8E@agw$)V@Of<~RT2>0c- z7Fsf}<_OMX%tED%aa0*910s(iEjtW()zb_Y6S1DEZ;^IQ9!SwyE|J&^Wn9E1hT^`| z8_JbF!-d-1Y2T_8=qqJh#WjY@9dRHVDsY5;?CCbmA}sGNPQOSZWRn~8%6JA-%qod!Ca+8xn&N`FwADaW}nI9N)-jnnS(aN z-Hd~$BbLE#VS%A}_MF&Ihhoo{78V&Eo*nK%=*4&pg$AVn!&u7;5!{bNbDuyIMSD4ToHvD z)gYZuR~>aoFhey;1=?jV=>Ldelu~_5MV|H)C0D*?z74q)17k~7BdPL7TrZ@$i!Dmq zsuwzP%`<_h3@BYR@@Q*CDIMuhbT_1KD>o`)o#7_qUc~$2sAKTwR4n`s*909c7V(hs zmvN)feHl%+lsf2jGur0BEx&ef=cp?)%%2R{ zZ|<3fzC2iJDMUl~{bU z%%cfoI0i;7>abmSR7;e z4OY`g=_w)auF)$nNpYEq*?EH7XJ5jnF*|YLKFxBZ;4^%l>X;`C69abQjtMeIfeD(C zZ48p2F?`WKcs~Xj49n~r0+X=JP*`RMuqu756>vf}*V!b=aR^g~=Z{g#1Y1Cbvcd>U zVm#jWavY8LHuHEuUvpT+8vSz0nXifD%XnG|{b*KYvxSkcjefQDcMA#^hIZS!4jSllTa zZ#TC^(PQX*YC5Jj#}G)S3L3%;*`l-!vB&KlOBjx*y3&&6+QyD6mBj{kO0G0ZM#Vo+ z;vP3l#}n>nUKH7;RffA^=;Zu^tVxfD5n<3Wk~~>lhlU=7r=8U1lv}#Wjn5&Vvm4s%us&wAeyN96cI_8CKhmvnXXL zBOOnXC|n&w2z22xu4ovI;3^U=)m^JqJYiE&WtD0?(BRaR2ElSXB3f`e9mX|=NquT+ zDv_7FM18{B5(!Epp+sLzZ!4iuvY)gGK@P6Gbc?OoujDXRz;pk zPZ;+Y#!njV_$}8k*yP9Q)Nr5SZTnfz^OnY#K|se09!2n2-T7{@CyG^15H~`@EW>j9 z5u5j%A()XR!O)vC9kEiiH--B_6@Vd@lSOV7xNE9?QyBEN%nHMsbEtF^L7E`g+@xk3 zOny8)IZ_4EbGJ-E<7cQDLUxTB{(K-EHlJ$iTBBVUqIT`D>HI(;)$tVY{W);ZIuVG9 zDD~ITZDn|O4nbO)i(=?&vg#Jaf~l$;X_jGHO#+PJ2|YLJ*8!pyowTNDXV90?H#B|# z_QO{{rB$hc0G`nrX&k^Dp3|-iFYuBiWXwi}3^}svK>i;* CD^DQ+ literal 0 HcmV?d00001 diff --git a/app/src/main/java/com/example/neurology_project_android/sampledata/interfaceExample/out/production/interfaceExample/com/example/neurology_project_android/sampledata/interfaceExample/GoodController.class b/app/src/main/java/com/example/neurology_project_android/sampledata/interfaceExample/out/production/interfaceExample/com/example/neurology_project_android/sampledata/interfaceExample/GoodController.class new file mode 100644 index 0000000000000000000000000000000000000000..b7ebce028c7231f1faaf69183039ec9927239c46 GIT binary patch literal 948 zcmb`GU2oGc6oy~-D{U9H(y}qW!^cJnQX_F&w2MsxRg`U_Q*S3XF~w72SFT&Jp9L38 z9{8mg#ysQXxhe?vaFm|BsMO3x^1v!8>cj!ukJc3_R*S+|OHhTQ|L ztkG#IOkhMTS*A$ho+Af?l83e!DH|yj-Dzq3>S`h>0~F> zxZ57CVGDOXZ2P!}`wYEUXOZ~Ivq>r&)Cebt(B;4|d=)!#F zu{d02ebjS4N(Dp7)yYr}d%gD1!$XFX|EIs#I=S8+GC9x*nQZi>5=XOaB+Ti;z1i0> zPlwz{H(!1%*)MX+@TtG*Lb?o9x7=Rd;iGT`2Frb<9?>dY>lr?*5X(>>=$VPdJL#I$ zy1B6(cbM=L59si_rvPdsDij~lETWOoSblX0_AUSYn4O$m z$E0x3)^~W#TR*Vzy78uwTw$|_Yt%Z40FUlc6;0G~i6j7CV27Y|VMtrR%fI^2 B1i%0Q literal 0 HcmV?d00001 diff --git a/app/src/main/java/com/example/neurology_project_android/sampledata/interfaceExample/out/production/interfaceExample/com/example/neurology_project_android/sampledata/interfaceExample/Movable.class b/app/src/main/java/com/example/neurology_project_android/sampledata/interfaceExample/out/production/interfaceExample/com/example/neurology_project_android/sampledata/interfaceExample/Movable.class new file mode 100644 index 0000000000000000000000000000000000000000..0853ac61badbd33daadd01f0b96b629218f8376e GIT binary patch literal 212 zcmXYrO9}!p5CvcSjlYG*(2co+pfC$j5Zp>T(+-hLT4EgZYA!s0hY}NUTU6KU_viTn zu)ruoOCc|$)_kY>Vp%)Bd9hMG4i_(HF2aFa>BW@#`V&eDq>T&gPgL;of3=o|j+QfY z6eeeCNLzB1-W*dwp;Jr43f*a*&-MzVio;f}CKz!F?P+4eo!orED`TTL{`cl7)l-O4 V{1<4&d>1{WnGF3%6b6Z7_ytXLI1&H= literal 0 HcmV?d00001 diff --git a/app/src/main/java/com/example/neurology_project_android/sampledata/interfaceExample/out/production/interfaceExample/com/example/neurology_project_android/sampledata/interfaceExample/Person.class b/app/src/main/java/com/example/neurology_project_android/sampledata/interfaceExample/out/production/interfaceExample/com/example/neurology_project_android/sampledata/interfaceExample/Person.class new file mode 100644 index 0000000000000000000000000000000000000000..ee3edb31a754f6f6f95e74f8ed64c61afeab8845 GIT binary patch literal 1495 zcmbtU>rN9v6#k|yY+Dx4au>v{qPF$2-cgGeXf)PTCBdM^A7*GrSlRA0yHkWW@u#n# z@sc#r2k>2Z1>>3B6^ao5kjfJzn#Bn_lY^q`mF$UDBlZI^qi z_Oq3DqOKTv=N!*bcNvm};&KLQWDI0Y^uc6!RF_R#Z1QHy6}BhZzI5g4)|-|uwe1`3 zHGJtb>>yNVaK&xMQ^J4C>*7IdwJ7{RdJO4$+xLa1UNW$%b~9jNQ2Y3M@G66&$Qc+i zF^rKtRF}2_C7Q%gwu$gaEeyhu_M%VjmXt4evz()N$4rc4f?=%5*F{Bob*^4GYK>-r z;+_f^Mhn$yF|rSnK*o-@TFzn;QwENkn8pc)p#vN+7%lD2rC3G^`yq*&)s#=u#~h4?7YQ_Y22Vn z#fMH{aTOm7_Tpw1w{Y7)SvSNS!_02mJKly|7j{(ms7D_1x{^MT|Il66STSPsh+5qh z!Awoc^>(Ygzx-W0dkl5#2dKa9(vL1;xw|c6hzfVzB}WNLw^{W(;a6N91R^kSkKy#b zm=2<9V4gDhe^q(|qPUQ!dzqj%K1DL2p8&d434KjzOwm5QAtX=JYChBh220<;O3AM< zwCMj78k`|HfF#KznWc~+!?Uz9$(T$~K#@j<63)eNE2OFQ$4iqMYKovf$1mvb!1~9R zemsF3hUnYnKfJ@=-ocxV(~%*(Tz(JX2xOEpJJtn~i9wRML@pAzj4Lz_&}^27T@B@R Q+@ZZJt?%MKJ?0aC0Mi<0GXMYp literal 0 HcmV?d00001 diff --git a/app/src/main/java/com/example/neurology_project_android/sampledata/interfaceExample/out/production/interfaceExample/com/example/neurology_project_android/sampledata/interfaceExample/Vehicle.class b/app/src/main/java/com/example/neurology_project_android/sampledata/interfaceExample/out/production/interfaceExample/com/example/neurology_project_android/sampledata/interfaceExample/Vehicle.class new file mode 100644 index 0000000000000000000000000000000000000000..d97b5eb1d2f6e4092dd91a28fd0cd08f14cc93a9 GIT binary patch literal 1616 zcmb_d>24D-5dNGd-8O6w!qJu^1SpUM=yG4-4lS)34v{KI@B_IUlP=xZt6c}tH{me= zQIPn-1MpCY@ou0AiTH&gd2G+|eKVff{P_9pJAnIG&LV{o3uzk}j518W;cvO)bJ=p9 ztiKViW*EKcNl)Kq7%7$-;~2xZg{+NTuo+fd)po=?-VS`>NYM$EuUcELf>0UXS6ntj zHPiByzPK3UwWNvQ;sllQyOdVy3N{!Z$a& zZRD_r!FD@gD5S0jLNpoJic#*fu^%%GQ(m+vTb>lpI#N+QU8loITEH$Y1RjMjgt45W2 z+-a|iaE-5e@j=X&z3+7wRGz)6QJaaXHw zi{X9mvvds;8p|{=zKG^)N^N$6%HUh|-1IrFZ3SY8`O6;rWSlF# Date: Mon, 12 Jan 2026 12:00:20 -0500 Subject: [PATCH 11/14] - Changes to align the Ui of the mobile application with the UI of the web client ** Changed colors in the UI for creating forms ** Changed size of text in UI for creating forms --- .../NewNIHFormActivity.kt | 28 +++++++++++++++---- 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/example/neurology_project_android/NewNIHFormActivity.kt b/app/src/main/java/com/example/neurology_project_android/NewNIHFormActivity.kt index 7b3492b..b2afcc6 100644 --- a/app/src/main/java/com/example/neurology_project_android/NewNIHFormActivity.kt +++ b/app/src/main/java/com/example/neurology_project_android/NewNIHFormActivity.kt @@ -8,6 +8,7 @@ import androidx.activity.compose.setContent import androidx.activity.viewModels import androidx.annotation.RequiresApi import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -20,6 +21,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults @@ -35,6 +37,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.semantics.getOrNull @@ -139,7 +142,8 @@ class NewNIHFormActivity : ComponentActivity() { // When an option is clicked, notify the ViewModel with the question index and the option's score onOptionClick = { score -> viewModel.onScoreSelected(index, score) - } + }, + ) } } @@ -195,18 +199,22 @@ class NewNIHFormActivity : ComponentActivity() { modifier = Modifier .fillMaxWidth() .padding(vertical = 8.dp), - elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + colors = CardDefaults.cardColors( + containerColor = Color.hsv(268.9F, saturation = .165F, value = 1.0F, alpha = 1.0F) + ) ) { Column( modifier = Modifier .padding(16.dp) + ) { // --- FIX 4: Use properties from the new FormQuestion data class --- Text(text = question.questionText, fontWeight = FontWeight.Bold, fontSize = 18.sp) if (question.instructionText.isNotEmpty()) { Text( text = question.instructionText, - fontSize = 14.sp, + fontSize = 18.sp, color = Color.Gray, modifier = Modifier.padding(bottom = 8.dp) ) @@ -216,20 +224,28 @@ class NewNIHFormActivity : ComponentActivity() { question.options.forEach { option -> Row( modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .border( + width = 2.dp, + color = Color.LightGray, + shape = RoundedCornerShape(8.dp) + ) .fillMaxWidth() .background( // Highlight the row if its score matches the selected score - if (selectedScore == option.score) MaterialTheme.colorScheme.primaryContainer else Color.Transparent + if (selectedScore == option.score) Color.hsv(270.0F, saturation = .477F, value = .987F, alpha = 1.0F) else Color.White ) + .clickable { onOptionClick(option.score) } // Pass the option's actual score up .padding(8.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { // --- FIX 5: Use properties from the new FormOption data class --- - Text(text = option.displayText, modifier = Modifier.weight(1f)) - Text(text = "${option.score}") + Text(text = option.displayText,fontSize = 18.sp, modifier = Modifier.weight(1f)) + Text(text = "${option.score}",fontSize = 18.sp) } + Spacer(modifier = Modifier.height(8.dp)) } } } From 2e7074c7f348b5f9fcb1828f3747544aafb29fc3 Mon Sep 17 00:00:00 2001 From: David Date: Wed, 18 Feb 2026 15:55:36 -0500 Subject: [PATCH 12/14] - changes to fix broken login attempts --- app/build.gradle.kts | 20 ++++++++++++------- app/src/debug/res/AndroidManifest.xml | 7 +++++++ .../neurology_project_android/MainActivity.kt | 4 +++- .../MainViewModel.kt | 3 ++- .../neurology_project_android/NIHForm.kt | 2 +- .../main/res/xml/network_security_config.xml | 9 +++++++++ 6 files changed, 35 insertions(+), 10 deletions(-) create mode 100644 app/src/debug/res/AndroidManifest.xml create mode 100644 app/src/main/res/xml/network_security_config.xml diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ed0f643..329d86d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -34,6 +34,7 @@ android { buildConfigField("int", "PORT", "444") buildConfigField("boolean", "SECURE", "true") // yes use HTTPS buildConfigField("String", "API_POST_URL","\"https://meechie.techkit.xyz:444/key=peerjs/post\"") + buildConfigField("String", "API_GET_ID_URL", "\"https://meechie.techkit.xyz:444/key=peerjs/id\"") buildConfigField("String", "API_GET_PEERS_URL","\"https://videochat-signaling-app.ue.r.appspot.com/key=peerjs/peers\"") signingConfig = signingConfigs.getByName("debug") } @@ -50,6 +51,7 @@ android { buildConfigField("int", "PORT", "444") buildConfigField("boolean", "SECURE", "true") // yes use HTTPS buildConfigField("String", "API_POST_URL","\"https://meechie.techkit.xyz:444/key=peerjs/post\"") + buildConfigField("String", "API_GET_ID_URL", "\"https://meechie.techkit.xyz:444/key=peerjs/id\"") buildConfigField("String", "API_GET_PEERS_URL","\"https://videochat-signaling-app.ue.r.appspot.com/key=peerjs/peers\"") signingConfig = signingConfigs.getByName("debug") } @@ -64,14 +66,18 @@ android { applicationIdSuffix = ".local" // e.g., com.example.myapp.local versionNameSuffix = "-local" - // Override BASE_API_URL to point to your local development server +// Override BASE_API_URL to point to your local development server // 10.0.2.2 is the special IP for your host machine's loopback on Android Emulator - buildConfigField("String", "BASE_API_URL", "\"localhost\"") - buildConfigField("String", "BASE_WS_API_URL", "\"ws://localhost:9000\"") - buildConfigField("int", "PORT", "9000") - buildConfigField("boolean", "SECURE", "false") // no use HTTPS - buildConfigField("String", "API_POST_URL","\"https://localhost:9000/key=peerjs/post\"") - buildConfigField("String", "API_GET_PEERS_URL","\"http://localhost:9000/key=peerjs/peers\"") + buildConfigField("String", "BASE_API_URL", "\"https://10.0.2.2\"") // Use http for local unless you have SSL set up for this IP + buildConfigField("String", "BASE_WS_API_URL", "\"ws://10.0.2.2:3016\"") + buildConfigField("int", "PORT", "3016") + buildConfigField("boolean", "SECURE", "false") // It's common to use http (not https) for local IP access + + // --- THIS IS THE FIX --- + // Replace 127.0.0.1 with 10.0.2.2 + buildConfigField("String", "API_POST_URL", "\"http://10.0.2.2:3016/key=peerjs/post\"") + buildConfigField("String", "API_GET_PEERS_URL", "\"http://10.0.2.2:3016/key=peerjs/peers\"") + // ------------------------ // If using a physical device on your local network, replace with your actual local IP: // buildConfigField("String", "BASE_API_URL", "\"http://192.168.1.XX:8080\"") diff --git a/app/src/debug/res/AndroidManifest.xml b/app/src/debug/res/AndroidManifest.xml new file mode 100644 index 0000000..6320ce2 --- /dev/null +++ b/app/src/debug/res/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/java/com/example/neurology_project_android/MainActivity.kt b/app/src/main/java/com/example/neurology_project_android/MainActivity.kt index 4e830e4..565061e 100644 --- a/app/src/main/java/com/example/neurology_project_android/MainActivity.kt +++ b/app/src/main/java/com/example/neurology_project_android/MainActivity.kt @@ -8,6 +8,7 @@ import android.R import android.annotation.SuppressLint import android.app.PendingIntent import android.content.Intent +import com.example.neurology_project_android.BuildConfig.API_GET_ID_URL import android.os.Build import android.os.Bundle import androidx.activity.ComponentActivity @@ -62,6 +63,7 @@ import androidx.media3.common.util.UnstableApi import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController +import com.example.neurology_project_android.BuildConfig.API_GET_PEERS_URL import com.example.neurology_project_android.ui.theme.NeurologyProjectAndroidTheme import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.CoroutineScope @@ -306,7 +308,7 @@ class MainActivity : ComponentActivity() { suspend fun fetchUserId(): String { return withContext(Dispatchers.IO) { try { - val idUrl = "https://videochat-signaling-app.ue.r.appspot.com/key=peerjs/id" + val idUrl = API_GET_ID_URL val client = OkHttpClient() val request = Request.Builder().url(idUrl).build() val response = client.newCall(request).execute() diff --git a/app/src/main/java/com/example/neurology_project_android/MainViewModel.kt b/app/src/main/java/com/example/neurology_project_android/MainViewModel.kt index 81d5f34..5d43d8b 100644 --- a/app/src/main/java/com/example/neurology_project_android/MainViewModel.kt +++ b/app/src/main/java/com/example/neurology_project_android/MainViewModel.kt @@ -8,6 +8,7 @@ import androidx.annotation.RequiresApi import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.example.neurology_project_android.BuildConfig.API_GET_ID_URL import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow @@ -76,7 +77,7 @@ class MainViewModel @Inject constructor( private suspend fun fetchUserId(): String { return withContext(Dispatchers.IO) { try { - val idUrl = "https://videochat-signaling-app.ue.r.appspot.com/key=peerjs/id" + val idUrl = API_GET_ID_URL val client = OkHttpClient() val request = Request.Builder().url(idUrl).build() val response = client.newCall(request).execute() diff --git a/app/src/main/java/com/example/neurology_project_android/NIHForm.kt b/app/src/main/java/com/example/neurology_project_android/NIHForm.kt index 5212ebf..48b7510 100644 --- a/app/src/main/java/com/example/neurology_project_android/NIHForm.kt +++ b/app/src/main/java/com/example/neurology_project_android/NIHForm.kt @@ -29,7 +29,7 @@ data class NIHForm( @SerialName("patient_dob") val patientDob: String? = null, - @SerialName("sessionid") + @SerialName("session_id") val sessionId: String? = null, // --- NIHSS Item Scores (all nullable) --- diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 0000000..81142b1 --- /dev/null +++ b/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,9 @@ + + + + + 10.0.2.2 + + + + From 3fe2fc8bf4e683acb50d7b4ad9285f8a8a28ade9 Mon Sep 17 00:00:00 2001 From: Lauren Viado Date: Tue, 24 Feb 2026 13:35:48 -0500 Subject: [PATCH 13/14] button --- .../CallScreenActivity.kt | 18 +++++------ .../neurology_project_android/FormManager.kt | 11 +++---- .../LoginActivity.kt | 2 +- .../NewNIHFormActivity.kt | 30 ++++++++++++++++--- .../NewNIHFormViewModel.kt | 5 ++++ 5 files changed, 46 insertions(+), 20 deletions(-) diff --git a/app/src/main/java/com/example/neurology_project_android/CallScreenActivity.kt b/app/src/main/java/com/example/neurology_project_android/CallScreenActivity.kt index a42d38e..1d28dd2 100644 --- a/app/src/main/java/com/example/neurology_project_android/CallScreenActivity.kt +++ b/app/src/main/java/com/example/neurology_project_android/CallScreenActivity.kt @@ -163,24 +163,22 @@ fun CallScreen() { // Center the items horizontally within the column horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.weight(1f).background(Color.Red).clickable(onClick = { - val intent = Intent( - context, - ListNIHFormActivity::class.java - ) - + val intent = Intent(context, NewNIHFormActivity::class.java).apply { + putExtra(NewNIHFormActivity.EXTRA_IN_CALL, true) + } context.startActivity(intent) + /* Open Stroke Scale Form */ }) ) { IconButton( onClick = { - val intent = Intent( - context, - ListNIHFormActivity::class.java - ) - // 3. Start the new Activity + val intent = Intent(context, NewNIHFormActivity::class.java).apply { + putExtra(NewNIHFormActivity.EXTRA_IN_CALL, true) + } context.startActivity(intent) + /* Open Stroke Scale Form */ }, modifier = Modifier .size(64.dp) diff --git a/app/src/main/java/com/example/neurology_project_android/FormManager.kt b/app/src/main/java/com/example/neurology_project_android/FormManager.kt index 48f53aa..4d24e18 100644 --- a/app/src/main/java/com/example/neurology_project_android/FormManager.kt +++ b/app/src/main/java/com/example/neurology_project_android/FormManager.kt @@ -15,6 +15,7 @@ import java.util.UUID object FormManager { + private const val POST_URL = "https://videochat-signaling-app.ue.r.appspot.com/key=peerjs/post" var TAG = "FormManager" fun submitFormToServer(form: NIHForm, client: OkHttpClient, onResult: (Boolean) -> Unit) { val jsonString = Gson().toJson(form) @@ -25,7 +26,7 @@ object FormManager { ) val request = Request.Builder() - .url("https://meechie.techkit.xyz:444/key=peerjs/post") + .url(POST_URL) .post(requestBody) .addHeader("Content-Type", "application/json") .addHeader("Action", "start_new_nihss_form") @@ -55,7 +56,7 @@ object FormManager { "application/json; charset=utf-8".toMediaTypeOrNull(), json.toString()) val request = Request.Builder() - .url("https://meechie.techkit.xyz:444/key=peerjs/post") + .url(POST_URL) .post(requestBody) .addHeader("Content-Type", "application/json") .addHeader("Action", "requestSingleForm") @@ -102,7 +103,7 @@ object FormManager { ) val request = Request.Builder() - .url("https://meechie.techkit.xyz:444/key=peerjs/post") + .url(POST_URL) .post(requestBody) .addHeader("Content-Type", "application/json") .addHeader("Action", "getUsersForms") @@ -138,7 +139,7 @@ object FormManager { ) val request = Request.Builder() - .url("https://meechie.techkit.xyz:444/key=peerjs/post") + .url(POST_URL) .post(requestBody) .addHeader("Content-Type", "application/json") .addHeader("Action", "deleteForm") @@ -173,7 +174,7 @@ object FormManager { ) val request = Request.Builder() - .url("https://meechie.techkit.xyz:444/key=peerjs/post") + .url(POST_URL) .post(requestBody) .addHeader("Content-Type", "application/json") .addHeader("Action", "updateForm") diff --git a/app/src/main/java/com/example/neurology_project_android/LoginActivity.kt b/app/src/main/java/com/example/neurology_project_android/LoginActivity.kt index e6fc8d6..9ab7b67 100644 --- a/app/src/main/java/com/example/neurology_project_android/LoginActivity.kt +++ b/app/src/main/java/com/example/neurology_project_android/LoginActivity.kt @@ -130,7 +130,7 @@ fun LoginScreen(sessionManager: SessionManager, onLoginSuccess: () -> Unit) { "application/json".toMediaTypeOrNull(), json ) - val postURL = API_POST_URL + val postURL = "https://meechie.techkit.xyz:444/b/key=peerjs/post" Log.d("LoginActivity", "Post URL: " + postURL) val request = Request.Builder() .url(postURL) diff --git a/app/src/main/java/com/example/neurology_project_android/NewNIHFormActivity.kt b/app/src/main/java/com/example/neurology_project_android/NewNIHFormActivity.kt index b2afcc6..3566c72 100644 --- a/app/src/main/java/com/example/neurology_project_android/NewNIHFormActivity.kt +++ b/app/src/main/java/com/example/neurology_project_android/NewNIHFormActivity.kt @@ -2,6 +2,7 @@ package com.example.neurology_project_android import android.os.Build import android.os.Bundle +import android.util.Log import android.widget.Toast import androidx.activity.ComponentActivity import androidx.activity.compose.setContent @@ -62,22 +63,27 @@ class NewNIHFormActivity : ComponentActivity() { // The `by viewModels()` delegate handles everything for you. private val viewModel: NewNIHFormViewModel by viewModels() + companion object { + const val EXTRA_IN_CALL = "extra_in_call" + } + @RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + val inCall = intent.getBooleanExtra(EXTRA_IN_CALL, false) // Hilt provides the ViewModel and its dependencies automatically. // No manual setup needed. setContent { // 2. Simply pass the Hilt-provided ViewModel to your screen. - NewNIHFormScreen(viewModel = viewModel) + NewNIHFormScreen(viewModel = viewModel, inCall = inCall) } } // This is the Composable function your Activity is trying to call. @RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM) @Composable - fun NewNIHFormScreen(viewModel: NewNIHFormViewModel) { + fun NewNIHFormScreen(viewModel: NewNIHFormViewModel, inCall: Boolean) { // 1. Observe state directly from the ViewModel val patientName by viewModel.patientName val itemScores by viewModel.itemScores @@ -136,14 +142,18 @@ class NewNIHFormActivity : ComponentActivity() { // --- FIX 2: Use the new NIHFormModel instead of StrokeScaleQuestions --- itemsIndexed(NIHFormModel.questions) { index, question -> QuestionCard( + questionIndex = index, question = question, // Get the currently selected score for this question from the ViewModel's state selectedScore = itemScores.getOrNull(index), + inCall = inCall, // When an option is clicked, notify the ViewModel with the question index and the option's score onOptionClick = { score -> viewModel.onScoreSelected(index, score) }, - + onAutomateClick = { + viewModel.automateQuestion(index) + } ) } } @@ -194,7 +204,7 @@ class NewNIHFormActivity : ComponentActivity() { * A reusable Composable for displaying a single question, its options, and highlighting the selection. */ @Composable - fun QuestionCard(question: FormQuestion, selectedScore: Int?, onOptionClick: (Int) -> Unit) { // --- FIX 3: Use FormQuestion class --- + fun QuestionCard(questionIndex: Int, question: FormQuestion, selectedScore: Int?, inCall: Boolean, onOptionClick: (Int) -> Unit, onAutomateClick: () -> Unit) { // --- FIX 3: Use FormQuestion class --- Card( modifier = Modifier .fillMaxWidth() @@ -220,6 +230,18 @@ class NewNIHFormActivity : ComponentActivity() { ) } + // Show automate only for question index 6 AND only in a call + if (inCall && questionIndex == 6) { + Button( + onClick = onAutomateClick, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp) + ) { + Text("Automate Question") + } + } + // Create a clickable row for each answer option question.options.forEach { option -> Row( diff --git a/app/src/main/java/com/example/neurology_project_android/NewNIHFormViewModel.kt b/app/src/main/java/com/example/neurology_project_android/NewNIHFormViewModel.kt index 4a80d6d..a5c8dd0 100644 --- a/app/src/main/java/com/example/neurology_project_android/NewNIHFormViewModel.kt +++ b/app/src/main/java/com/example/neurology_project_android/NewNIHFormViewModel.kt @@ -56,6 +56,11 @@ class NewNIHFormViewModel @Inject constructor(private val client: SignalingClien } } + fun automateQuestion(questionIndex: Int) { + // TODO: implement automation logic later + Log.d("NEWNIHFORMVIEWMODEL", "Automate clicked for question index = $questionIndex") + } + /** * Called by the View when the "Save" button is clicked. * It validates the input, builds the NIHForm object, and calls the repository to submit it. From 92c67bc49843af93a2737287c6b2bc4da811ca41 Mon Sep 17 00:00:00 2001 From: Christiana Date: Thu, 26 Feb 2026 19:11:08 -0500 Subject: [PATCH 14/14] Button showing in form --- .../neurology_project_android/LoginActivity.kt | 2 +- .../interfaceExample/GoodController.class | Bin 948 -> 0 bytes 2 files changed, 1 insertion(+), 1 deletion(-) delete mode 100644 app/src/main/java/com/example/neurology_project_android/sampledata/interfaceExample/out/production/interfaceExample/com/example/neurology_project_android/sampledata/interfaceExample/GoodController.class diff --git a/app/src/main/java/com/example/neurology_project_android/LoginActivity.kt b/app/src/main/java/com/example/neurology_project_android/LoginActivity.kt index 9ab7b67..e6fc8d6 100644 --- a/app/src/main/java/com/example/neurology_project_android/LoginActivity.kt +++ b/app/src/main/java/com/example/neurology_project_android/LoginActivity.kt @@ -130,7 +130,7 @@ fun LoginScreen(sessionManager: SessionManager, onLoginSuccess: () -> Unit) { "application/json".toMediaTypeOrNull(), json ) - val postURL = "https://meechie.techkit.xyz:444/b/key=peerjs/post" + val postURL = API_POST_URL Log.d("LoginActivity", "Post URL: " + postURL) val request = Request.Builder() .url(postURL) diff --git a/app/src/main/java/com/example/neurology_project_android/sampledata/interfaceExample/out/production/interfaceExample/com/example/neurology_project_android/sampledata/interfaceExample/GoodController.class b/app/src/main/java/com/example/neurology_project_android/sampledata/interfaceExample/out/production/interfaceExample/com/example/neurology_project_android/sampledata/interfaceExample/GoodController.class deleted file mode 100644 index b7ebce028c7231f1faaf69183039ec9927239c46..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 948 zcmb`GU2oGc6oy~-D{U9H(y}qW!^cJnQX_F&w2MsxRg`U_Q*S3XF~w72SFT&Jp9L38 z9{8mg#ysQXxhe?vaFm|BsMO3x^1v!8>cj!ukJc3_R*S+|OHhTQ|L ztkG#IOkhMTS*A$ho+Af?l83e!DH|yj-Dzq3>S`h>0~F> zxZ57CVGDOXZ2P!}`wYEUXOZ~Ivq>r&)Cebt(B;4|d=)!#F zu{d02ebjS4N(Dp7)yYr}d%gD1!$XFX|EIs#I=S8+GC9x*nQZi>5=XOaB+Ti;z1i0> zPlwz{H(!1%*)MX+@TtG*Lb?o9x7=Rd;iGT`2Frb<9?>dY>lr?*5X(>>=$VPdJL#I$ zy1B6(cbM=L59si_rvPdsDij~lETWOoSblX0_AUSYn4O$m z$E0x3)^~W#TR*Vzy78uwTw$|_Yt%Z40FUlc6;0G~i6j7CV27Y|VMtrR%fI^2 B1i%0Q