안녕하세요 여러분. 크리에이트메이커 입니다.
제가 외주를 받아서 원격제어 앱을 제작중인데요,
차근차근 포스팅을 하려합니다.
이번포스팅에선는 소켓통신을 이용하여 화면출력하는 부분을 보여드리겠습니다.
먼저 원격제어를 하려면 기본적으로 앱이 2개가 필요하겠죠?
(1개로도 가능하지만, 상용화를 대비하면 2개로 하는게 좋습니다.)
그럼 먼저 server쪽을 보겠습니다.
(보통 서버를 제어하는쪽으로 하는 경우가 많은데, 서버는 제어 당하는 쪽에서 열어야 합니다. 팀뷰어에서 아이디랑 비번 알려주는 기능이 서버를 여는거라 보시면 됩니다.)
코틀린이기 때문에 코틀린 코드로 제작하겠습니다.
MainActivity
class MainActivity : ComponentActivity() {
@RequiresApi(Build.VERSION_CODES.O)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
RemotecontrolclientTheme {
// A surface container using the 'background' color from the theme
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
Greeting("Android")
}
}
}
checkAndRequestPermissions()
startScreenCaptureService()
}
override fun onTouchEvent(event: MotionEvent): Boolean {
when (event.action) {
MotionEvent.ACTION_DOWN -> {
Log.d("AccessibilityService", "Screen clicked! no clkic12");
return true
}
MotionEvent.ACTION_UP -> {
Log.d("AccessibilityService", "Screen clicked! no clkic23");
return true
}
else -> return super.onTouchEvent(event)
}
}
private fun startScreenCaptureService() {
val mediaProjectionManager =
getSystemService(MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
val captureIntent = mediaProjectionManager.createScreenCaptureIntent()
startActivityForResult(captureIntent, REQUEST_CODE_CAPTURE)
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (requestCode == REQUEST_CODE_CAPTURE && resultCode == RESULT_OK) {
val serviceIntent = Intent(this, ScreenCaptureService::class.java)
serviceIntent.putExtra("resultCode", resultCode)
serviceIntent.putExtra("data", data)
startService(serviceIntent)
}
}
private fun checkAndRequestPermissions() {
val permissionsToRequest = mutableListOf<String>()
// READ_EXTERNAL_STORAGE 권한 확인
if (!checkPermission(Manifest.permission.READ_EXTERNAL_STORAGE)) {
permissionsToRequest.add(Manifest.permission.READ_EXTERNAL_STORAGE)
}
// WRITE_EXTERNAL_STORAGE 권한 확인
if (!checkPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE)) {
permissionsToRequest.add(Manifest.permission.WRITE_EXTERNAL_STORAGE)
}
if (!checkPermission(Manifest.permission.INTERNET)) {
permissionsToRequest.add(Manifest.permission.INTERNET)
}
if (!checkPermission(Manifest.permission.SYSTEM_ALERT_WINDOW)) {
permissionsToRequest.add(Manifest.permission.SYSTEM_ALERT_WINDOW)
}
if (!checkPermission(Manifest.permission.FOREGROUND_SERVICE)) {
permissionsToRequest.add(Manifest.permission.FOREGROUND_SERVICE)
}
if (!checkPermission(Manifest.permission.SYSTEM_ALERT_WINDOW)) {
permissionsToRequest.add(Manifest.permission.SYSTEM_ALERT_WINDOW)
}
if (!checkPermission(Manifest.permission.BIND_ACCESSIBILITY_SERVICE)) {
permissionsToRequest.add(Manifest.permission.BIND_ACCESSIBILITY_SERVICE)
}
// 다른 권한들도 필요한 경우, 여기에 추가
// 권한이 없는 경우 요청
if (permissionsToRequest.isNotEmpty()) {
requestPermissionLauncher.launch(permissionsToRequest.toTypedArray())
}
}
// 특정 권한이 이미 허용되어 있는지 확인하는 함수
private fun checkPermission(permission: String): Boolean {
return ContextCompat.checkSelfPermission(
this,
permission
) == PackageManager.PERMISSION_GRANTED
}
private val requestPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { permissions ->
val allGranted = permissions.all { it.value }
if (allGranted) {
// 모든 권한이 허용된 경우
// 필요한 작업을 수행합니다.
} else {
// 권한이 거부된 경우
// 사용자에게 권한이 필요한 이유를 설명하거나 다른 조치를 취할 수 있습니다.
}
}
companion object {
private const val PERMISSION_REQUEST_CODE = 100
private const val REQUEST_CODE_SCREEN_CAPTURE = 100
private const val FOREGROUND_SERVICE_ID = 1000
private const val REQUEST_CODE_CAPTURE = 1001
}
}
@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
Text(
text = "Hello $name!",
modifier = modifier
)
}
@Preview(showBackground = true)
@Composable
fun GreetingPreview() {
RemotecontrolclientTheme {
Greeting("Android")
}
}
화면 전송을 위해서는 먼저 권한과, 노티피케이션과 화면스크린샷 허용 intent기능을 요청해야합니다.
제어 당하는 쪽은 사실 앱으 화면에 표출될 필요가 없기 때문에 빈 액티비티에서 작업하면 되지만, 저는 티스트를 위해 문자 하나 나오는 앱이 실행이 됩니다.
코드를 잘 분석해 보시면, service를 실행하는 부분이 있을겁니다.
네, 서비스 class코드를 또 따로 작성해줘야 합니다.
왜냐하면 원격제어를 당할때, 제어자는 상대방 앱을 제어하는것이 아닌 폰을 제어해야 하기때문에 결국 앱을 닫아야 하기 때문입니다.
그 상태로 계속 원격제어를 하려면 사실 제어 기능은 백그라운드에서 실행이 되고 있어야 하겠죠?
자 그럼 service코드 입니다.
class ScreenCaptureService : Service() {
private lateinit var mediaProjectionManager: MediaProjectionManager
private var mediaProjection: MediaProjection? = null
private var serverSocket: ServerSocket? = null
private var socket: Socket? = null
private lateinit var imageReader: ImageReader
private lateinit var virtualDisplay: VirtualDisplay
private lateinit var screenCaptureThread: Thread
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
screenCaptureThread = object : Thread() {
override fun run() {
createNotificationChannel()
val resultCode = intent?.getIntExtra("resultCode", -1)
val data = intent?.getParcelableExtra<Intent>("data")
if (resultCode == -1 && data != null) {
mediaProjectionManager =
getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
mediaProjection = mediaProjectionManager.getMediaProjection(resultCode, data)
Log.d(TAG, "master enter. can capture")
}
while (true) {
serverSocket = ServerSocket(8380)
println("master enter wait")
serverSocket?.setSoTimeout(5000);
try {
socket = serverSocket!!.accept()
println("master enter")
var xcontrol = 0
var ycontrol = 0
try {
while (true) {
var bitmap = startScreenCapture()
val byteArrayOutputStream = ByteArrayOutputStream()
bitmap.compress(
Bitmap.CompressFormat.JPEG,
100,
byteArrayOutputStream
)
val byteArray = byteArrayOutputStream.toByteArray()
val dataOutputStream = DataOutputStream(socket!!.getOutputStream())
dataOutputStream.writeInt(100)
dataOutputStream.writeInt(byteArray.size)
dataOutputStream.write(byteArray)
dataOutputStream.flush()
Log.d(TAG, "master enter. sending display")
val inputStream = DataInputStream(socket!!.getInputStream())
val target = inputStream.readInt()
if (target == 50) {
xcontrol = inputStream.readInt()
ycontrol = inputStream.readInt()
if (xcontrol == 0 && ycontrol == 0) {
val sharedPreferences =
applicationContext.getSharedPreferences(
"touchposition",
Context.MODE_PRIVATE
)
val editor = sharedPreferences.edit()
editor.putFloat("xposition", 0F)
editor.putFloat("yposition", 0F)
editor.apply()
} else {
val sharedPreferences =
applicationContext.getSharedPreferences(
"touchposition",
Context.MODE_PRIVATE
)
val editor = sharedPreferences.edit()
editor.putFloat("xposition", xcontrol.toFloat())
editor.putFloat("yposition", ycontrol.toFloat())
editor.apply()
}
}
sleep(100)
}
sleep(100) // 1초마다 반복
} catch (e: InterruptedException) {
Log.d(TAG, "master enter. err end")
interrupt()
}
serverSocket?.close()
// 클라이언트와의 통신을 계속
} catch (e: IOException) {
Log.d(TAG, "master enter. end")
serverSocket?.close()
sleep(1000)
}
}
}
}
screenCaptureThread.start()
return START_NOT_STICKY
}
private fun startScreenCapture(): Bitmap {
val displayMetrics = DisplayMetrics()
val windowManager = getSystemService(Context.WINDOW_SERVICE) as WindowManager
windowManager.defaultDisplay.getMetrics(displayMetrics)
val screenWidth = displayMetrics.widthPixels
val screenHeight = displayMetrics.heightPixels
val density = (displayMetrics.density).toInt()
imageReader = ImageReader.newInstance(
screenWidth, screenHeight, RGBA_8888, 1
)
var image: Image? = null
while (image == null) {
image = imageReader.acquireLatestImage()
if (image == null) {
virtualDisplay = mediaProjection!!.createVirtualDisplay(
"ScreenCapture",
screenWidth,
screenHeight,
density,
DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
imageReader.surface,
null,
null
)
}
}
val planes = image.planes
val buffer = planes[0].buffer
val pixelStride = planes[0].pixelStride
val rowStride = planes[0].rowStride
val rowPadding = rowStride - pixelStride * screenWidth
val bitmap = Bitmap.createBitmap(
screenWidth + rowPadding / pixelStride,
screenHeight,
Bitmap.Config.ARGB_8888
)
bitmap.copyPixelsFromBuffer(buffer)
image.close()
Log.d(TAG, "master enter image bitmap " + bitmap)
return bitmap
}
override fun onBind(intent: Intent?): IBinder? {
return null
}
override fun onDestroy() {
super.onDestroy()
mediaProjection?.stop()
}
private fun createNotificationChannel() {
val builder = Notification.Builder(this.applicationContext)
val nfIntent = Intent(this, MainActivity::class.java)
builder.setContentIntent(
PendingIntent.getActivity(
this,
0,
nfIntent,
PendingIntent.FLAG_MUTABLE
)
)
.setLargeIcon(
BitmapFactory.decodeResource(
this.resources,
R.drawable.alert_dark_frame
)
)
.setSmallIcon(R.mipmap.sym_def_app_icon)
.setContentText("is running......")
.setWhen(System.currentTimeMillis())
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
builder.setChannelId("notification_id")
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
val channel = NotificationChannel(
"notification_id",
"notification_name",
NotificationManager.IMPORTANCE_LOW
)
notificationManager.createNotificationChannel(channel)
}
val notification = builder.build()
notification.defaults = Notification.DEFAULT_SOUND
startForeground(110, notification)
}
companion object {
}
}
코드를 보시면 소켓서버를 열고 클라이언트가 오길 대기합니다.(소켓 관련 알고리즘을 알아서 기능좋게 수정하세용)
여기서 중요한점은 notificate입니다. 화면캡쳐는 안드로이드 문서와 달리 노티피케이션이 없으면 실행되지 않습니다.
처음 노티 먼저 해결 한 후, 스크린샷 허용 intent가 가 허용을 받게 되면, intent값을 service에 전달 해주게 됩니다.
그럼 service에서 그 값을 받고 새로 선언한 mediaprojection에 적용시키고 service에서 화면캡쳐를 실행하게 됩니다.
그 캡쳐 자료를 imagereader에 읽히고, bitmap변환하여 소켓 전송하면 됩니다.
그렇게 되면 제어를 접속한 사람은 그 화면 스크린샷을 볼 수 가 있겠죠?
이때, 원격 터치를 위해서 스크린샷 이미지를 핸드폰 사이즈에 맞게 보내던지, 아니면 화질(이미지용량)을 줄이면서, 터치위치를 정확히 하는 계산식을 넣어주면 됩니다. 보시면 xpostion, yposition이 원격하는 사람이 그 스크린샷을 클릭한 위치 입니다. 즉, 1000x2000 의 이미지를 보내면, 원격제어하는 사람이 그 이미지중 어떤 앱 이 있는 곳을 터치하면,
그 좌표가 소켓으로 원격지에 전달이 됩니다. 그럼 원격지에서는 그 받은 좌표를 터치하라는 명령을 줘야죠. 이때,
사진상 좌표는 500x500인데, 만약 핸드폰 사이즈(픽셀)가 1500x3000 이면 실제로 이상한곳을 터치하겠죠? 비율이 다르니까요. 이런 부분을 계산해서 정확히 터치하게 하는 식이 필요합니다.
사실 서버 코드가 끝나면 원격제어 프로그램은 거의 80퍼 완성된 것입니다. 화면 송출 + 제어자가 터치한 부분을 터치하는 명령 이게 되면 원격이 끝나니까요. 중요한건 어떻게 이 원격을 유지하냐 입니다.
핸드폰 화면이 off가되도 원격제어자가 켤 수 있냐 없냐, 등 이런 부분들이 가능하게끔 백그라운드 작업을 계속 유지가능하게 코드를 하게 되면 좀더 섬세한 원격제어 앱이 되겠죠.
제가 현제 제공한 코드에는 터치 좌표만 받고 터치를 하는 명령은 없습니다.
터치하는 명령을
AccessibilityService
를 이용하여 제작해볼 계획인데 다음 포스팅에 알려드리도록 하겠습니다.
혹시 더 좋은 코드나 방법이 있다면 댓글로 알려주세요~