From 74803af21b7b880eaeef3b907ec7b115d1989e73 Mon Sep 17 00:00:00 2001 From: ritoseo Date: Thu, 25 Sep 2025 01:46:32 +0900 Subject: [PATCH] =?UTF-8?q?UI=EA=B0=80=EB=8F=85=EC=84=B1=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20=EB=B0=8F=20VFD=20=EC=A0=9C=EC=96=B4=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20FakeView=20incoming=20call=20=EB=8F=99=EC=9E=91=20?= =?UTF-8?q?=EB=90=98=EB=8F=84=EB=A1=9D=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/build.gradle | 10 +- app/src/main/AndroidManifest.xml | 4 + app/src/main/assets/config.static | 1 + .../com/tutpro/baresip/plus/BaresipService.kt | 123 ++++-- .../kotlin/com/tutpro/baresip/plus/Config.kt | 8 + .../com/tutpro/baresip/plus/MainActivity.kt | 372 +++++++++++++++++- .../kotlin/com/tutpro/baresip/plus/Utils.kt | 158 ++++++++ .../res/drawable/bg_button_selector_radio.xml | 15 + .../drawable/bg_button_selector_setting.xml | 24 ++ app/src/main/res/layout/activity_main.xml | 250 ++++++++++-- 10 files changed, 888 insertions(+), 77 deletions(-) create mode 100644 app/src/main/res/drawable/bg_button_selector_radio.xml create mode 100644 app/src/main/res/drawable/bg_button_selector_setting.xml diff --git a/app/build.gradle b/app/build.gradle index d52af13..8a1e549 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -9,8 +9,8 @@ android { applicationId = 'kr.co.rito.ritosip' minSdkVersion 29 targetSdkVersion 35 - versionCode = 104 - versionName = '1.0.4' + versionCode = 106 + versionName = '1.0.6' externalNativeBuild { cmake { cFlags '-DHAVE_INTTYPES_H -lstdc++' @@ -86,5 +86,11 @@ dependencies { implementation "androidx.activity:activity-ktx:1.9.3" implementation "androidx.fragment:fragment-ktx:1.8.5" implementation 'androidx.media:media:1.7.0' + + def camerax_version = "1.3.3" // 1.3.x 이상 권장 + implementation "androidx.camera:camera-core:$camerax_version" + implementation "androidx.camera:camera-camera2:$camerax_version" + implementation "androidx.camera:camera-lifecycle:$camerax_version" + implementation "androidx.camera:camera-view:$camerax_version" } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index da656b9..03e0e07 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -25,6 +25,10 @@ android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="32" tools:ignore="ScopedStorage" /> + diff --git a/app/src/main/assets/config.static b/app/src/main/assets/config.static index ea0ec0c..dbc927d 100644 --- a/app/src/main/assets/config.static +++ b/app/src/main/assets/config.static @@ -45,6 +45,7 @@ module_app mwi.so avcodec_h264enc h264_mediacodec video_fps 30 mic_volume 20 +aux_volume 80 evdev_device /dev/input/event0 opus_samplerate 48000 opus_stereo no diff --git a/app/src/main/kotlin/com/tutpro/baresip/plus/BaresipService.kt b/app/src/main/kotlin/com/tutpro/baresip/plus/BaresipService.kt index 25561b9..03c7fbd 100644 --- a/app/src/main/kotlin/com/tutpro/baresip/plus/BaresipService.kt +++ b/app/src/main/kotlin/com/tutpro/baresip/plus/BaresipService.kt @@ -54,8 +54,10 @@ import androidx.media.AudioFocusRequestCompat import androidx.media.AudioManagerCompat import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.json.JSONArray import org.json.JSONObject import java.io.File @@ -187,6 +189,7 @@ class BaresipService: Service() { Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK newIntent.putExtra("action", "network available") + //newIntent.putExtra("address", netInfo.get("ip")) newIntent.putExtra("address", deviceIpAddress) @@ -205,6 +208,8 @@ class BaresipService: Service() { allNetworks.remove(network) if (isServiceRunning) updateNetwork() + + Utils.renderVfdString("NO,NETWORK") } override fun onCapabilitiesChanged(network: Network, caps: NetworkCapabilities) { @@ -564,19 +569,38 @@ class BaresipService: Service() { if(param != null) { val json = JSONObject(param) val device_name = json.getString("device_name") - val mic_volume = json.getString("mic_volume") val display_type = json.getString("display_type") Config.replaceVariable("device_name", device_name) BaresipService.deviceName = device_name - val mic_volume_int = mic_volume.toInt() - if(mic_volume_int >= 0 && mic_volume_int <= 24) { - Utils.setMicVolumeByMix(mic_volume_int) + try { + val mic_volume = json.getString("mic_volume") + val mic_volume_int = mic_volume.toInt() + if (mic_volume_int >= 0 && mic_volume_int <= 24) { + if (mic_volume_int != BaresipService.micVolume) { + Utils.setMicVolumeByMix(mic_volume_int) + } + } + } catch(e : Exception) { + } + + try { + val aux_volume = json.getString("aux_volume") + val aux_volume_int = aux_volume.toInt() + if(aux_volume_int >= 0 && aux_volume_int <= 127) { + if(aux_volume_int != BaresipService.auxVolume) { + Utils.setAuxVolumeByMix(aux_volume_int) + } + } + } catch(e : Exception) { } Config.replaceVariable("far_view_display_id", display_type) - BaresipService.farViewDisplayId = display_type.toInt() + try { + BaresipService.farViewDisplayId = display_type.toInt() + } catch(e:Exception) { + } Config.save() sendActivityAction("update info") @@ -759,7 +783,7 @@ class BaresipService: Service() { } val scope = CoroutineScope(Dispatchers.Default) - var captureCount : Long = 0 + //var captureCount : Long = 0 var running = true val job = scope.launch { @@ -811,7 +835,10 @@ class BaresipService: Service() { val bufSize : Int = farBuf.capacity() / 2 var width : Int = 1280 var height : Int = 720 - if(bufSize == 1280 * 720) { + if(bufSize == 720 * 480) { + width = 720 + height = 480 + } else if(bufSize == 1280 * 720) { width = 1280 height = 720 } else if(bufSize == 1920 * 1080) { @@ -989,23 +1016,49 @@ class BaresipService: Service() { //call.setVideoSource(!BaresipService.cameraFront) val prevCamera2 = Utils.propertyGet("sys.ritosip.camera2.status") Utils.propertySet("sys.ritosip.camera2.status", camera2) + + if(camera1 == "plugin" && prevCamera1 != "plugin") { + sendActivityAction("camera connected"); + } else if(camera1 != "plugin" && prevCamera1 == "plugin") { + sendActivityAction("camera removed"); + } + + var cameraCount = 0 + if(camera1 == "plugin") { + cameraCount++ + } + if(camera2 == "plugin") { + cameraCount++ + } + BaresipService.connectedCameraCount = cameraCount + + if(camera1 == "plugin" && camera2 != "plugin") { + BaresipService.sourceSelect = 1 + } else if(camera1 != "plugin" && camera2 == "plugin") { + BaresipService.sourceSelect = 2 + } else if(prevCamera2 != "plugin" && camera2 == "plugin") { + BaresipService.sourceSelect = 2 + } + if(camera1 != "plugin" && camera2 != "plugin" && cameraState != 0) { if(uas.size > 0) { val ua = uas[0] val call = ua.currentCall() if(call != null) { + delay(200) call.setVideoFake() cameraState = 0 + println("setVideoFake 실행") } } - } else if(camera1 == "plugin" && camera2 != "plugin" && (cameraState == 0 || cameraState == 2)) { + } else if(camera1 == "plugin" && (camera2 != "plugin" || BaresipService.sourceSelect == 1) && (cameraState == 0 || cameraState == 2)) { if(uas.size > 0) { val ua = uas[0] val call = ua.currentCall() if(call != null) { - Log.d(TAG, "RITO camera1 detect!") + Log.d(TAG, "RITO camera1 detect!, sourceSelect = ${BaresipService.sourceSelect}, cameraState = ${cameraState}") //val process = Runtime.getRuntime().exec("ritosysc shell-order=killall android.hardware.camera.provider@2.4-service android.hardware.camera.provider@2.4-external-service cameraserver android.hardware.tv.input@1.0-service") if(cameraState == 0) { val process = Runtime.getRuntime() @@ -1016,24 +1069,31 @@ class BaresipService: Service() { } BaresipService.cameraFront = true; call.setVideoSource(true) + if(cameraState == 2) { + showCustomToast(applicationContext, "입력소스 HDMI-1로 전환됩니다.", Toast.LENGTH_SHORT) + } cameraState = 1 //delay(3000) // 1초마다 실행 } } - } else if(camera2 == "plugin" && cameraState != 2) { + } else if(camera2 == "plugin" && (camera1 != "plugin" || BaresipService.sourceSelect != 1) && cameraState != 2) { if(uas.size > 0) { val ua = uas[0] val call = ua.currentCall() if(call != null) { - Log.d(TAG, "RITO camera2 detect!") + Log.d(TAG, "RITO camera2 detect!, sourceSelect = ${BaresipService.sourceSelect}, cameraState = ${cameraState}") //val process = Runtime.getRuntime().exec("ritosysc shell-order=killall android.hardware.camera.provider@2.4-service android.hardware.camera.provider@2.4-external-service cameraserver android.hardware.tv.input@1.0-service") // val process = Runtime.getRuntime().exec("ritosysc shell-order=killall cameraserver") // process.waitFor() // process.destroy() // delay(1500) // 1초마다 실행 + if(cameraState == 1 || camera1 == "plugin") { + showCustomToast(applicationContext, "입력소스 HDMI-2로 전환됩니다.", Toast.LENGTH_SHORT) + } BaresipService.cameraFront = false; call.setVideoSource(false) + cameraState = 2 //delay(3000) // 1초마다 실행 } @@ -1063,6 +1123,7 @@ class BaresipService: Service() { if(call == null) { cameraState = -1 + BaresipService.sourceSelect = 1 } } else { cameraState = -1 @@ -1152,7 +1213,9 @@ class BaresipService: Service() { if(cameraState == -1) { if(camera1 == "plugin") { //Utils.runShellOrderSuper("killall -9 v4l2-ctl") - Utils.runShellOrderSuper("timeout 2s v4l2-ctl --device=/dev/video20 --stream-mmap=3 --stream-to=/mnt/obb/camera.rgb888 --stream-count=1 && chmod 666 /mnt/obb/camera.rgb888") + if(!useMonitoringSelfView) { + Utils.runShellOrderSuper("timeout 2s v4l2-ctl --device=/dev/video20 --stream-mmap=3 --stream-to=/mnt/obb/camera.rgb888 --stream-count=1 && chmod 666 /mnt/obb/camera.rgb888") + } try { val nearBuf = Utils.readFileToByteBuffer("/mnt/obb/camera.rgb888") @@ -1288,7 +1351,11 @@ class BaresipService: Service() { CallHistoryNew.save() } - val value = Utils.readNumberFromFile("/sdcard/Documents/sip_total_duration.txt") + var value = Utils.readNumberFromFile("/sdcard/Documents/sip_total_duration.txt") + if(value == null) { + Utils.runShellOrderSuper("appops set kr.co.rito.ritosip MANAGE_EXTERNAL_STORAGE allow") + value = Utils.readNumberFromFile("/sdcard/Documents/sip_total_duration.txt") + } if(value != null) { BaresipService.totalDurationSeconds = value } @@ -1353,7 +1420,7 @@ class BaresipService: Service() { applyTransportConfiguration() Utils.setMicVolumeByMix(BaresipService.micVolume) - + Utils.setAuxVolumeByMix(BaresipService.auxVolume) } "Start Content Observer" -> { @@ -1680,6 +1747,7 @@ class BaresipService: Service() { } return } + // callp holds SIP message pointer Api.ua_accept(uap, callp) return @@ -2275,17 +2343,21 @@ class BaresipService: Service() { } } - fun showCustomToast(context: Context, message: String) { - val inflater = LayoutInflater.from(context) - val layout: View = inflater.inflate(R.layout.custom_toast, null) + fun showCustomToast(context: Context, message: String, duration: Int = Toast.LENGTH_LONG) { + GlobalScope.launch(Dispatchers.IO) { + withContext(Dispatchers.Main) { + val inflater = LayoutInflater.from(context) + val layout: View = inflater.inflate(R.layout.custom_toast, null) - val textView: TextView = layout.findViewById(R.id.toast_text) - textView.text = message + val textView: TextView = layout.findViewById(R.id.toast_text) + textView.text = message - val toast = Toast(context) - toast.duration = Toast.LENGTH_LONG - toast.view = layout - toast.show() + val toast = Toast(context) + toast.duration = duration + toast.view = layout + toast.show() + } + } } private fun getActionText(@StringRes stringRes: Int, @ColorRes colorRes: Int): Spannable { @@ -2686,6 +2758,11 @@ class BaresipService: Service() { var audioDeviceUsbId = -1 var audioDeviceCodecId = -1 var micVolume = 20 + var auxVolume = 80 + var sourceSelect = 1 + var useMonitoringSelfView = false + var captureCount : Long = 0 // 로컬 프리뷰와 함께 캡쳐를 위해 companion object로 변경 + var connectedCameraCount = 0 fun updateAudioSourceDevice(ctx: Context) { val am = ctx.getSystemService(Context.AUDIO_SERVICE) as AudioManager diff --git a/app/src/main/kotlin/com/tutpro/baresip/plus/Config.kt b/app/src/main/kotlin/com/tutpro/baresip/plus/Config.kt index 9d1e06e..0f862d9 100644 --- a/app/src/main/kotlin/com/tutpro/baresip/plus/Config.kt +++ b/app/src/main/kotlin/com/tutpro/baresip/plus/Config.kt @@ -219,6 +219,14 @@ object Config { config = "${config}mic_volume ${BaresipService.micVolume}\n" } + val auxVolume = previousVariable("aux_volume") + if (auxVolume != "") { + config = "${config}aux_volume $auxVolume\n" + BaresipService.auxVolume = auxVolume.toInt() + } else { + config = "${config}aux_volume ${BaresipService.auxVolume}\n" + } + for (size in defaultSizes) videoSizes.add(size.toString()) /********************/ diff --git a/app/src/main/kotlin/com/tutpro/baresip/plus/MainActivity.kt b/app/src/main/kotlin/com/tutpro/baresip/plus/MainActivity.kt index bef9d4b..785a710 100644 --- a/app/src/main/kotlin/com/tutpro/baresip/plus/MainActivity.kt +++ b/app/src/main/kotlin/com/tutpro/baresip/plus/MainActivity.kt @@ -11,9 +11,10 @@ import android.content.* import android.content.Intent.ACTION_CALL import android.content.Intent.ACTION_DIAL import android.content.Intent.ACTION_VIEW -import android.content.pm.PackageInfo import android.content.pm.PackageManager import android.content.res.Configuration +import android.graphics.Bitmap +import android.graphics.Matrix import android.graphics.Rect import android.hardware.display.DisplayManager import android.media.AudioManager @@ -32,10 +33,12 @@ import androidx.activity.OnBackPressedCallback import androidx.activity.enableEdgeToEdge import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts +import androidx.annotation.OptIn import androidx.annotation.RequiresApi import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatDelegate import androidx.appcompat.widget.AppCompatButton +import androidx.camera.view.PreviewView import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat import androidx.core.graphics.Insets @@ -50,17 +53,20 @@ import com.google.android.material.snackbar.Snackbar import com.tutpro.baresip.plus.Utils.getAppVersion import com.tutpro.baresip.plus.Utils.showSnackBar import com.tutpro.baresip.plus.databinding.ActivityMainBinding -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch import org.json.JSONObject import java.io.File import java.text.SimpleDateFormat import java.time.LocalDateTime import java.time.format.DateTimeFormatter import java.util.* +import java.util.concurrent.Executors import kotlin.system.exitProcess +import java.io.FileOutputStream +import androidx.camera.core.* +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.camera.camera2.interop.Camera2CameraInfo +import androidx.camera.camera2.interop.ExperimentalCamera2Interop class SecondScreenPresentation(context: Context, display: Display) : Presentation(context, display) { @@ -75,6 +81,68 @@ class SecondScreenPresentation(context: Context, display: Display) : Presentatio } } +private class FrameSaver( + private val outputDir: File +) : ImageAnalysis.Analyzer { + + private var lastSavedAt = 0L + private val sdf = SimpleDateFormat("yyyyMMdd_HHmmss_SSS", Locale.US) + + override fun analyze(image: ImageProxy) { + try { + val now = System.currentTimeMillis() + // 1초 간격 + if (now - lastSavedAt >= 1000) { + val bitmap = imageProxyToBitmap(image) ?: return + // 회전 보정 + val rotated = rotateBitmap(bitmap, image.imageInfo.rotationDegrees.toFloat()) + + // 파일 저장 (JPG) + var nearFilePath = "/mnt/obb/near_${BaresipService.captureCount}.jpg" + //val file = File(outputDir, "${sdf.format(now)}.jpg") + val file = File(nearFilePath) + FileOutputStream(file).use { fos -> + rotated.compress(Bitmap.CompressFormat.JPEG, 90, fos) + + val destFile = File(nearFilePath) + destFile.setReadable(true, false) + Utils.propertySet("sys.ritosip.near_screen_file", nearFilePath) + } + lastSavedAt = now + } + } catch (t: Throwable) { + t.printStackTrace() + } finally { + image.close() // 반드시 닫아야 다음 프레임이 들어옵니다. + } + } + + // RGBA_8888 → Bitmap + private fun imageProxyToBitmap(image: ImageProxy): Bitmap? { +// if (image.format != ImageFormat.UNKNOWN && +// image.format != ImageFormat.RGBA_8888 +// ) return null + + val plane = image.planes.firstOrNull() ?: return null + val buffer = plane.buffer + buffer.rewind() + + val width = image.width + val height = image.height + val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + bitmap.copyPixelsFromBuffer(buffer) + return bitmap + } + + private fun rotateBitmap(src: Bitmap, degrees: Float): Bitmap { + if (degrees == 0f) return src + val m = Matrix().apply { postRotate(degrees) } + return Bitmap.createBitmap(src, 0, 0, src.width, src.height, m, true).also { + if (it != src) src.recycle() + } + } +} + class MainActivity : AppCompatActivity() { private lateinit var binding: ActivityMainBinding @@ -147,6 +215,10 @@ class MainActivity : AppCompatActivity() { private lateinit var networkButton: AppCompatButton private lateinit var backwardButton: AppCompatButton + private lateinit var previewView: PreviewView + private var imageAnalyzer: ImageAnalysis? = null + private var cameraExecutor = Executors.newSingleThreadExecutor() + private var callHandler: Handler = Handler(Looper.getMainLooper()) private var callRunnable: Runnable? = null private var downloadsInputUri: Uri? = null @@ -197,11 +269,136 @@ class MainActivity : AppCompatActivity() { } lateinit var navUpList : HashMap + lateinit var navUpListAlter : HashMap lateinit var navDownList : HashMap + lateinit var navDownListAlter : HashMap lateinit var navUpServerList : HashMap lateinit var navDownServerList : HashMap + private var cameraProvider: ProcessCameraProvider? = null + + @OptIn(ExperimentalCamera2Interop::class) + private fun cameraSelectorById(targetId: String): CameraSelector { + return CameraSelector.Builder() + .addCameraFilter { cameraInfos: List -> + cameraInfos.filter { info -> + Camera2CameraInfo.from(info).cameraId == targetId + } + } + .build() + } + + + private fun stopSelfMoitoringCamera() { + if(BaresipService.useMonitoringSelfView) { + binding.selfViewLayout.visibility = View.INVISIBLE +// val cameraProviderFuture = ProcessCameraProvider.getInstance(this) +// val cameraProvider = cameraProviderFuture.get() + cameraProvider?.unbindAll() + } + } + @SuppressLint("RestrictedApi") + private fun resetSelfMoitoringCamera() { + if(BaresipService.useMonitoringSelfView) { + for(i in 0..1) { + cameraProvider?.unbindAll() + cameraProvider?.shutdown() + cameraExecutor.shutdown() + } + + cameraExecutor = Executors.newSingleThreadExecutor() + } + } + +// @SuppressLint("RestrictedApi") +// private fun reinitializeCamera(reason: String) { +// // 1) 모든 UseCase 해제 +// cameraProvider?.unbindAll() +// +// // 2) provider 완전 종료 +// val oldProvider = cameraProvider +// cameraProvider = null +// +// if (oldProvider != null) { +// val shutdownFuture = oldProvider.shutdown() // ListenableFuture +// shutdownFuture.addListener({ +// // 3) 재시작 +// startCameraWithId(targetCameraId) +// }, ContextCompat.getMainExecutor(this)) +// } else { +// // 바로 시작 +// startCameraWithId(targetCameraId) +// } +// } + + private fun startSelfMoitoringCamera() { + if(!BaresipService.useMonitoringSelfView) + return; + + binding.selfViewLayout.visibility = View.VISIBLE + val cameraProviderFuture = ProcessCameraProvider.getInstance(this) + + cameraProviderFuture.addListener({ + cameraProvider = cameraProviderFuture.get() + + // Preview + val preview = Preview.Builder().build().apply { + setSurfaceProvider(previewView.surfaceProvider) + } + + // ImageAnalysis: 프리뷰 프레임을 가져옴 + imageAnalyzer = ImageAnalysis.Builder() + // RGBA_8888로 받으면 YUV→RGB 변환 없이 바로 Bitmap 생성 가능 + .setOutputImageFormat(ImageAnalysis.OUTPUT_IMAGE_FORMAT_RGBA_8888) + // 최신 프레임만 유지 (버벅임 방지) + .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) + .build() + .also { ia -> + ia.setAnalyzer(cameraExecutor, FrameSaver(getOutputDir())) + } + + val cameraSelector = cameraSelectorById("120") + + try { + cameraProvider?.unbindAll() + val camera = cameraProvider?.bindToLifecycle( + this, cameraSelector, preview, imageAnalyzer + ) + + camera?.cameraInfo?.cameraState?.observe(this) { state -> + when (val type = state.type) { + CameraState.Type.OPEN, CameraState.Type.PENDING_OPEN -> { /* 정상/열리는 중 */ } + CameraState.Type.CLOSING, CameraState.Type.CLOSED -> { + // 닫힘 → 필요 시 재시도 타이머 + println("RITO Camera Closing or closed") + } + + else -> {} + } + state.error?.let { err -> + when (err.code) { + CameraState.ERROR_CAMERA_IN_USE, + CameraState.ERROR_MAX_CAMERAS_IN_USE, + CameraState.ERROR_CAMERA_DISABLED, + CameraState.ERROR_CAMERA_FATAL_ERROR -> { + // 복구 트리거 + println("RITO Camera Error code : ${err.code}") + } + } + } + } + } catch (e: Exception) { + e.printStackTrace() + } + }, ContextCompat.getMainExecutor(this)) + } + + private fun getOutputDir(): File { + // 앱 전용 Pictures 폴더 (권한 불필요) + val dir = getExternalFilesDir(Environment.DIRECTORY_PICTURES) + return File(dir, "preview_frames").apply { if (!exists()) mkdirs() } + } override fun dispatchKeyEvent(event: KeyEvent): Boolean { if(!isKeyboardVisible) { val currentFocusView = currentFocus @@ -214,10 +411,19 @@ class MainActivity : AppCompatActivity() { view.requestFocus() return false } else { - if(currentFocusView == binding.btnApply) { - binding.radioDhcp.requestFocus() - return false + if(navUpListAlter.contains(currentFocusView)) { + val view = navUpListAlter.get(currentFocusView)!! + if(view.isVisible) { + view.requestFocus() + return false + } } + + +// if(currentFocusView == binding.btnApply) { +// binding.radioDhcp.requestFocus() +// return false +// } } } else { if(currentFocusView == binding.aorSpinner) { @@ -250,6 +456,14 @@ class MainActivity : AppCompatActivity() { if(view.isVisible) { view.requestFocus() return false + } else { + if(navDownListAlter.contains(currentFocusView)) { + val view = navDownListAlter.get(currentFocusView)!! + if(view.isVisible) { + view.requestFocus() + return false + } + } } } if(navDownServerList.contains(currentFocusView)) { @@ -281,6 +495,9 @@ class MainActivity : AppCompatActivity() { if(navUpList.contains(currentFocusView) || navDownList.contains(currentFocusView)) { found = true } +// if(!found && navDownListAlter.contains(currentFocusView)) { +// found = true +// } if(navUpServerList.contains(currentFocusView) || navDownServerList.contains(currentFocusView)) { found = true } @@ -369,6 +586,7 @@ class MainActivity : AppCompatActivity() { private fun updateFieldsVisibility() { val isStatic = binding.radioStatic.isChecked val visibility = if (isStatic) View.VISIBLE else View.GONE + binding.layoutStaticIp.visibility = visibility binding.editIp.visibility = visibility binding.editNetmask.visibility = visibility binding.editGateway.visibility = visibility @@ -478,6 +696,8 @@ class MainActivity : AppCompatActivity() { dialpadButton = binding.dialpadButton swipeRefresh = binding.swipeRefresh + previewView = binding.previewView + dialogImgBtn1 = binding.dialogButtonImg1 dialogImgBtn1.setOnClickListener { @@ -639,6 +859,10 @@ class MainActivity : AppCompatActivity() { binding.seekbarMicVolume.progress = BaresipService.micVolume val currentValue = binding.seekbarMicVolume.progress binding.textMicVolume.setText(currentValue.toString()) + + binding.seekbarAuxVolume.progress = BaresipService.auxVolume + val currentValueAux = binding.seekbarAuxVolume.progress + binding.textAuxVolume.setText(currentValueAux.toString()) } binding.dialogButtonVolumeInCall.setOnClickListener { @@ -649,6 +873,10 @@ class MainActivity : AppCompatActivity() { binding.seekbarMicVolume.progress = BaresipService.micVolume val currentValue = binding.seekbarMicVolume.progress binding.textMicVolume.setText(currentValue.toString()) + + binding.seekbarAuxVolume.progress = BaresipService.auxVolume + val currentValueAux = binding.seekbarAuxVolume.progress + binding.textAuxVolume.setText(currentValueAux.toString()) } binding.seekbarMicVolume.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener{ @@ -656,7 +884,7 @@ class MainActivity : AppCompatActivity() { // progress: 현재 값 // fromUser: 사용자가 직접 움직였는지 여부 val currentValue = progress - println("현재 값: $currentValue") + println("현재 MIC 값: $currentValue") binding.textMicVolume.setText(currentValue.toString()) Utils.setMicVolumeByMix(currentValue) } @@ -671,6 +899,26 @@ class MainActivity : AppCompatActivity() { } }) + binding.seekbarAuxVolume.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener{ + override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) { + // progress: 현재 값 + // fromUser: 사용자가 직접 움직였는지 여부 + val currentValue = progress + println("현재 AUX 값: $currentValue") + binding.textAuxVolume.setText(currentValue.toString()) + Utils.setAuxVolumeByMix(currentValue) + } + + override fun onStartTrackingTouch(seekBar: SeekBar?) { + // 터치 시작 시 호출 + + } + + override fun onStopTrackingTouch(seekBar: SeekBar?) { + // 터치 끝날 때 호출 + } + }) + binding.btnDoneVolume.setOnClickListener { binding.volumeSettingLayout.visibility = View.INVISIBLE if(binding.settingLayout.visibility == View.VISIBLE) { @@ -731,21 +979,29 @@ class MainActivity : AppCompatActivity() { imm.showSoftInput(it, InputMethodManager.SHOW_IMPLICIT) } navUpList = HashMap() + navUpListAlter = HashMap() navDownList = HashMap() + navDownListAlter = HashMap() navUpList.put(binding.editIp, binding.radioStatic) navUpList.put(binding.editNetmask, binding.editIp) navUpList.put(binding.editGateway, binding.editNetmask) navUpList.put(binding.editDns, binding.editGateway) navUpList.put(binding.btnApply, binding.editDns) + navUpList.put(binding.btnCancel, binding.editDns) + navUpListAlter.put(binding.btnCancel, binding.radioStatic) + navUpListAlter.put(binding.btnApply, binding.radioDhcp) //navUpList.put(binding.btnApply, binding.radioDhcp) + navDownList.put(binding.radioDhcp, binding.editIp) navDownList.put(binding.radioStatic, binding.editIp) + navDownListAlter.put(binding.radioStatic, binding.btnApply) + navDownListAlter.put(binding.radioDhcp, binding.btnApply) navDownList.put(binding.editIp, binding.editNetmask) navDownList.put(binding.editNetmask, binding.editGateway) navDownList.put(binding.editGateway, binding.editDns) navDownList.put(binding.editDns, binding.btnApply) - navDownList.put(binding.radioDhcp, binding.btnApply) + //navDownList.put(binding.radioDhcp, binding.btnApply) // setDpadNavigation(binding.editIp, downView = binding.editNetmask) // setDpadNavigation(binding.editNetmask, upView = binding.editIp, downView = binding.editGateway) @@ -767,6 +1023,31 @@ class MainActivity : AppCompatActivity() { showCustomToast(applicationContext, "DHCP 적용되었습니다.") binding.networkSettingLayout.visibility = View.INVISIBLE } else { + if(binding.editIp.text.length == 0 || + binding.editNetmask.text.length == 0 || + binding.editGateway.text.length == 0 || + binding.editDns.text.length == 0) { + showCustomToast(applicationContext, "비어있는 항목이 있습니다.") + return@setOnClickListener + } + + if(Utils.IpValidator.detectIpType(binding.editIp.text.toString()) == Utils.IpType.NONE) { + showCustomToast(applicationContext, "IP가 유효하지 않습니다.") + return@setOnClickListener + } + if(Utils.IpValidator.detectIpType(binding.editNetmask.text.toString()) == Utils.IpType.NONE) { + showCustomToast(applicationContext, "NETMASK가 유효하지 않습니다.") + return@setOnClickListener + } + if(Utils.IpValidator.detectIpType(binding.editGateway.text.toString()) == Utils.IpType.NONE) { + showCustomToast(applicationContext, "GATEWAY가 유효하지 않습니다.") + return@setOnClickListener + } + if(Utils.IpValidator.detectIpType(binding.editDns.text.toString()) == Utils.IpType.NONE) { + showCustomToast(applicationContext, "DNS가 유효하지 않습니다.") + return@setOnClickListener + } + val params: HashMap = HashMap() params.put("IPV4TYPE", "STATIC") params.put("DEVTYPE", "ETHERNET") @@ -834,6 +1115,22 @@ class MainActivity : AppCompatActivity() { binding.dialogButtonServer.requestFocus() } + binding.btnPingTestServer.setOnClickListener { _ -> + Thread { + val rtts = Utils.ping(binding.editSipServer.text.toString(), 1) + if (rtts.isNotEmpty()) { + val avg = rtts.average() + runOnUiThread { + showCustomToast(applicationContext, "PING 성공하였습니다. RTT: ${"%.2f".format(avg)} ms") + } + } else { + runOnUiThread { + showCustomToast(applicationContext, "PING 실패하였습니다.") + } + } + }.start() + } + navUpServerList = HashMap() navDownServerList = HashMap() @@ -1528,6 +1825,9 @@ class MainActivity : AppCompatActivity() { } } + //Utils.runShellOrder("sendserial /dev/ttyUSB10 115200 term '@123456'") + Utils.renderVfdString(",") + } // OnCreate fun sendSettingByBroadcast(req : String, param : String) { @@ -2547,12 +2847,15 @@ class MainActivity : AppCompatActivity() { Utils.alertView(this, getString(R.string.notice), getString(R.string.no_network)) */ + Utils.renderVfdString("NO,NETWORK") return } /* Added by ritoseo */ "network available" -> { val tv = findViewById(R.id.textViewHeaderIp) tv.setText(intent.getStringExtra("address")!!) + + Utils.renderVfdString(tv.text.toString()) } "video call" -> { callUri.setText(intent.getStringExtra("peer")!!) @@ -2571,6 +2874,17 @@ class MainActivity : AppCompatActivity() { "update info" -> { updateInfo() } + "camera connected" -> { + if(!isCallExist()) { + startSelfMoitoringCamera() + } + } + "camera removed" -> { + if(!isCallExist()) { + stopSelfMoitoringCamera() + resetSelfMoitoringCamera() + } + } /********************/ "call", "dial" -> { if (Call.inCall()) { @@ -2603,9 +2917,19 @@ class MainActivity : AppCompatActivity() { resumeCall = call if(ev[0] == "call answer") { + stopSelfMoitoringCamera() // 모니터링 셀프 뷰 종료 Handler(Looper.getMainLooper()).postDelayed({ videoButton.performClick() }, 1000) + + Handler(Looper.getMainLooper()).postDelayed({ + if(BaresipService.connectedCameraCount == 0) { + println("Answer Call setVideoFake 실행") + call.setVideoFake() + } + //call.setVideoDirection(Api.SDP_RECVONLY) + }, 1500) + } } "call missed" -> { @@ -2654,6 +2978,8 @@ class MainActivity : AppCompatActivity() { val BTN_MISC = 188 //val BTN_MENU = 58 val SCANCODE_OPTION = 357 //MENU키 -> OPTION키로 매핑 + val SCANCODE_RED = 398 + val SCANCODE_GREEN = 399 //println("onKeyDown : ${keyCode}") //println("onKeyDown2 : ${event?.scanCode}") val stream = if (am.mode == AudioManager.MODE_RINGTONE) @@ -2686,11 +3012,29 @@ class MainActivity : AppCompatActivity() { editable.delete(length - 1, length) } } + KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_DPAD_CENTER -> { + if(BaresipService.sourceSelect == 1) { + BaresipService.sourceSelect = 2 + } else { + BaresipService.sourceSelect = 1 + } + return true + } BTN_MISC -> { return true } } when (event?.scanCode) { + SCANCODE_RED -> { + if(!isCallExist()) { + startSelfMoitoringCamera() + } + } + SCANCODE_GREEN -> { + if(!isCallExist()) { + stopSelfMoitoringCamera() + } + } SCANCODE_OPTION -> { // val dialog = findViewById(R.id.dialogLayout) // if(dialog.isVisible) { @@ -2786,6 +3130,7 @@ class MainActivity : AppCompatActivity() { } updateDisplay() + BaresipService.supportedCameras = Utils.supportedCameras(applicationContext).isNotEmpty() handleNextEvent() return } @@ -2870,6 +3215,13 @@ class MainActivity : AppCompatActivity() { showCall(ua) } "call established" -> { + val ua = BaresipService.uas[0] + val call = ua.currentCall() + if(call != null) { + val callNo = call.peerUri.split("@")[0].filter { it.isDigit() } + Utils.renderVfdString("CALL,${callNo}") + } + if (aor == aorSpinner.tag) { dtmf.text = null dtmf.hint = getString(R.string.dtmf) @@ -3031,6 +3383,7 @@ class MainActivity : AppCompatActivity() { switchVideoLayout(false) //callVideoButton.requestFocus() + Utils.renderVfdString(binding.textViewHeaderIp.text.toString()) BaresipService.isMicMuted = false BaresipService.isVideoMuted = false } @@ -3653,6 +4006,7 @@ class MainActivity : AppCompatActivity() { } private fun makeCall(kind: String, lookForContact: Boolean = true) { + stopSelfMoitoringCamera() // 모니터링 Self 카메라 멈춤 callUri.setAdapter(null) val ua = BaresipService.uas[aorSpinner.selectedItemPosition] val aor = ua.account.aor diff --git a/app/src/main/kotlin/com/tutpro/baresip/plus/Utils.kt b/app/src/main/kotlin/com/tutpro/baresip/plus/Utils.kt index 64ac2ef..115a649 100644 --- a/app/src/main/kotlin/com/tutpro/baresip/plus/Utils.kt +++ b/app/src/main/kotlin/com/tutpro/baresip/plus/Utils.kt @@ -607,6 +607,31 @@ object Utils { } } + var VfdLock : Object = Object() + fun renderVfdString(info : String, align : String = "center") { + val file = File("/dev/ttyUSB10") + if (!file.exists()) { + return + } + Thread { + synchronized(VfdLock) { + var renderStr = "'@" + if (align == "center") { + var space = 0 + space = (16 - info.length) / 2 + + for (i in 1..space) { + renderStr += "," + } + + renderStr += info + renderStr += "'" + } + Utils.runShellOrder("sendserial /dev/ttyUSB10 115200 term ${renderStr}") + } + }.start() + } + fun putFileContents(filePath: String, contents: ByteArray): Boolean { try { File(filePath).writeBytes(contents) @@ -1372,6 +1397,108 @@ object Utils { } } + fun setAuxVolumeByMix(volume : Int) { + CoroutineScope(Dispatchers.Default).launch { + Utils.runShellOrderSuper("tinymix -D 2 \"ADC Capture Volume\" ${volume}") + BaresipService.auxVolume = volume + Config.replaceVariable("aux_volume", volume.toString()) + Config.save() + propertySet("sys.ritosip.aux.volume", volume.toString()) + } + } + + enum class IpType { IPv4, IPv6, NONE } + + object IpValidator { + + private val IPV4_REGEX = + Regex("""^(?:0|[1-9]\d{0,2})(?:\.(?:0|[1-9]\d{0,2})){3}$""") // 선행 0 금지(단, "0"은 허용) + + // ----- Public API ----- + fun detectIpType(input: String?): IpType { + val s = input?.trim().orEmpty() + if (s.isEmpty()) return IpType.NONE + return when { + isValidIPv4(s) -> IpType.IPv4 + isValidIPv6(s) -> IpType.IPv6 + else -> IpType.NONE + } + } + + fun isValidIPv4(s: String): Boolean { + if (!IPV4_REGEX.matches(s)) return false + // 각 옥텟 0..255 + return s.split('.').all { part -> + val v = part.toIntOrNull() ?: return false + v in 0..255 + } + } + + fun isValidIPv6(s: String): Boolean { + // 스코프 ID 분리 (예: fe80::1%eth0) + val zoneIndex = s.indexOf('%') + val (addr, zone) = if (zoneIndex >= 0) { + s.substring(0, zoneIndex) to s.substring(zoneIndex + 1) + } else s to null + + if (zone != null && zone.isEmpty()) return false + if (zone != null && !zone.all { it.isLetterOrDigit() || it == '.' || it == '_' || it == '-' }) return false + + // 최소 콜론 2개(IPv6 형태인지 빠른 거르기) + if (addr.count { it == ':' } < 2) return false + + // 임베디드 IPv4 처리 (마지막 부분에 IPv4가 올 수 있음) + val lastColon = addr.lastIndexOf(':') + val hasEmbeddedV4 = addr.contains('.') + val (base, v4tail) = if (hasEmbeddedV4 && lastColon >= 0) { + addr.substring(0, lastColon) to addr.substring(lastColon + 1) + } else addr to null + + if (v4tail != null && !isValidIPv4(v4tail)) return false + + // "::"는 최대 한 번만 허용 + val ddc = base.indexOf("::") + if (ddc != -1 && base.indexOf("::", startIndex = ddc + 1) != -1) return false + + val hextetRegex = Regex("^[0-9A-Fa-f]{1,4}$") + + fun splitHextets(part: String): List { + if (part.isEmpty()) return emptyList() + val pieces = part.split(':') + // 빈 조각은 허용하지 않음(단, "::" 압축에서만 허용) + if (pieces.any { it.isEmpty() }) return emptyList() + return pieces + } + + val (leftH, rightH) = if (ddc >= 0) { + val left = base.substring(0, ddc) + val right = base.substring(ddc + 2) + splitHextets(left) to splitHextets(right) + } else { + splitHextets(base) to emptyList() + } + + if (leftH.isEmpty() && ddc == -1 && base.isEmpty()) return false // 완전히 빈 주소는 불가 + + // 각 hextet 유효성 + if (leftH.any { !hextetRegex.matches(it) }) return false + if (rightH.any { !hextetRegex.matches(it) }) return false + + val explicitHextets = leftH.size + rightH.size + val totalUnits = explicitHextets + if (hasEmbeddedV4) 2 else 0 + + if (ddc >= 0) { + // 압축이 있는 경우: 총 단위(hextet 8개 기준)가 8보다 작아야 함(압축으로 채움) + if (totalUnits >= 8) return false + } else { + // 압축이 없는 경우: 총 단위가 정확히 8이어야 함 + if (totalUnits != 8) return false + } + + return true + } + } + fun runShellOrderSuper(order : String) { val process = Runtime.getRuntime().exec("ritosysc shell-order=" + order) process.waitFor() @@ -1409,6 +1536,13 @@ object Utils { return found } + fun setSourceSelect(idx : Int) { + if(idx >= -1 && idx <= 2) { + BaresipService.sourceSelect = idx + propertySet("sys.ritosip.source.select", idx.toString()) + } + } + fun checkCameraConnection(idx : Int): String { if(idx == 0) { val file = File("/d/hdmirx/status") // 파일 경로 설정 @@ -1547,6 +1681,30 @@ object Utils { return netInfo } + fun ping(host: String, count: Int = 4): List { + val command = arrayOf("/system/bin/ping", "-W", "2", "-c", count.toString(), host) + val process = ProcessBuilder(*command).redirectErrorStream(true).start() + val reader = BufferedReader(InputStreamReader(process.inputStream)) + + val regex = Regex("time=([0-9.]+) ms") + val rtts = mutableListOf() + + reader.useLines { lines -> + lines.forEach { line -> + // 로그에 찍기 (Logcat) + android.util.Log.d("PingUtil", line) + + val match = regex.find(line) + if (match != null) { + rtts.add(match.groupValues[1].toDouble()) + } + } + } + + process.waitFor() + return rtts + } + fun calculateNetmask(prefixLength: Int): String { val mask = (0xFFFFFFFF shl (32 - prefixLength)) return listOf( diff --git a/app/src/main/res/drawable/bg_button_selector_radio.xml b/app/src/main/res/drawable/bg_button_selector_radio.xml new file mode 100644 index 0000000..96f92b6 --- /dev/null +++ b/app/src/main/res/drawable/bg_button_selector_radio.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_button_selector_setting.xml b/app/src/main/res/drawable/bg_button_selector_setting.xml new file mode 100644 index 0000000..abd0251 --- /dev/null +++ b/app/src/main/res/drawable/bg_button_selector_setting.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 10c9540..1e634bd 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -933,6 +933,9 @@ android:focusable="true" android:focusableInTouchMode="true" android:paddingRight="15dp" + android:nextFocusLeft="@id/radioDhcp" + android:nextFocusRight="@id/radioStatic" + android:background="@drawable/bg_button_selector_radio" android:text="DHCP" android:textSize="24sp" /> @@ -942,42 +945,103 @@ android:layout_height="wrap_content" android:focusable="true" android:focusableInTouchMode="true" + android:nextFocusLeft="@id/radioDhcp" + android:nextFocusRight="@id/radioStatic" + android:background="@drawable/bg_button_selector_radio" android:paddingRight="15dp" android:text="STATIC" android:textSize="24sp" /> - + android:layout_marginTop="4dp" + android:visibility="gone" + android:orientation="vertical"> - + + + + - + + + + - + + + + + + + + + + + -