diff --git a/app/build.gradle b/app/build.gradle index c7583c6..332c5fa 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -33,6 +33,9 @@ dependencies { implementation 'androidx.core:core:1.6.0' implementation 'org.java-websocket:Java-WebSocket:1.5.4' implementation 'com.google.zxing:core:3.3.3' + implementation('com.journeyapps:zxing-android-embedded:3.6.0') { + exclude group: 'com.android.support' + } testImplementation 'junit:junit:4.13.2' } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f2ff5f5..1224a98 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -5,6 +5,9 @@ + + + + + @@ -29,9 +38,22 @@ + + + + + + diff --git a/app/src/main/java/com/m5dev/librecast/HomeActivity.java b/app/src/main/java/com/m5dev/librecast/HomeActivity.java new file mode 100644 index 0000000..8ed7c8d --- /dev/null +++ b/app/src/main/java/com/m5dev/librecast/HomeActivity.java @@ -0,0 +1,35 @@ +package com.m5dev.librecast; + +import android.content.Intent; +import android.os.Bundle; +import android.view.View; +import android.widget.Button; +import androidx.appcompat.app.AppCompatActivity; + +public class HomeActivity extends AppCompatActivity { + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_home); + + Button btnReceive = findViewById(R.id.btn_receive); + Button btnSend = findViewById(R.id.btn_send); + + btnReceive.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + Intent intent = new Intent(HomeActivity.this, MainActivity.class); + startActivity(intent); + } + }); + + btnSend.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + Intent intent = new Intent(HomeActivity.this, SenderActivity.class); + startActivity(intent); + } + }); + } +} diff --git a/app/src/main/java/com/m5dev/librecast/LibreCastClient.java b/app/src/main/java/com/m5dev/librecast/LibreCastClient.java new file mode 100644 index 0000000..dc0e1c3 --- /dev/null +++ b/app/src/main/java/com/m5dev/librecast/LibreCastClient.java @@ -0,0 +1,96 @@ +package com.m5dev.librecast; + +import android.util.Log; +import org.java_websocket.client.WebSocketClient; +import org.java_websocket.handshake.ServerHandshake; + +import java.net.URI; + +public class LibreCastClient extends WebSocketClient { + + private static final String TAG = "LibreCastClient"; + + public interface ClientListener { + void onConnected(); + void onDisconnected(); + void onError(Exception ex); + } + + private ClientListener listener; + + public LibreCastClient(URI serverUri) { + super(serverUri); + } + + public LibreCastClient(URI serverUri, ClientListener listener) { + super(serverUri); + this.listener = listener; + } + + public void setListener(ClientListener listener) { + this.listener = listener; + } + + @Override + public void onOpen(ServerHandshake handshakedata) { + logI(TAG, "WebSocket connected to " + getURI()); + if (listener != null) { + listener.onConnected(); + } + } + + @Override + public void onMessage(String message) { + logD(TAG, "Received message: " + message); + } + + @Override + public void onClose(int code, String reason, boolean remote) { + logI(TAG, "WebSocket closed: " + reason + " (code: " + code + ")"); + if (listener != null) { + listener.onDisconnected(); + } + } + + @Override + public void onError(Exception ex) { + logE(TAG, "WebSocket error", ex); + if (listener != null) { + listener.onError(ex); + } + } + + public void sendFrame(byte[] data) { + if (isOpen() && data != null && data.length > 0) { + try { + send(data); + } catch (Exception e) { + logE(TAG, "Error sending frame over WebSocket", e); + } + } + } + + private static void logI(String tag, String msg) { + try { + Log.i(tag, msg); + } catch (Throwable t) { + System.out.println("[" + tag + "] " + msg); + } + } + + private static void logD(String tag, String msg) { + try { + Log.d(tag, msg); + } catch (Throwable t) { + System.out.println("[" + tag + "] " + msg); + } + } + + private static void logE(String tag, String msg, Throwable t) { + try { + Log.e(tag, msg, t); + } catch (Throwable ignored) { + System.err.println("[" + tag + "] " + msg); + } + } +} diff --git a/app/src/main/java/com/m5dev/librecast/ScreenCaptureService.java b/app/src/main/java/com/m5dev/librecast/ScreenCaptureService.java new file mode 100644 index 0000000..921e9cb --- /dev/null +++ b/app/src/main/java/com/m5dev/librecast/ScreenCaptureService.java @@ -0,0 +1,352 @@ +package com.m5dev.librecast; + +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.Service; +import android.content.Context; +import android.content.Intent; +import android.content.pm.ServiceInfo; +import android.hardware.display.DisplayManager; +import android.hardware.display.VirtualDisplay; +import android.media.MediaCodec; +import android.media.MediaCodecInfo; +import android.media.MediaFormat; +import android.media.projection.MediaProjection; +import android.media.projection.MediaProjectionManager; +import android.os.Build; +import android.os.IBinder; +import android.util.DisplayMetrics; +import android.util.Log; +import android.view.Surface; +import androidx.core.app.NotificationCompat; + +import java.net.URI; +import java.nio.ByteBuffer; + +public class ScreenCaptureService extends Service implements LibreCastClient.ClientListener { + + private static final String TAG = "ScreenCaptureService"; + + public static final String ACTION_START = "com.m5dev.librecast.action.START_CAST"; + public static final String ACTION_STOP = "com.m5dev.librecast.action.STOP_CAST"; + public static final String ACTION_STATUS_CHANGED = "com.m5dev.librecast.action.CAST_STATUS_CHANGED"; + + public static final String EXTRA_RESULT_CODE = "extra_result_code"; + public static final String EXTRA_RESULT_DATA = "extra_result_data"; + public static final String EXTRA_TARGET_URI = "extra_target_uri"; + + public static final String EXTRA_IS_CASTING = "extra_is_casting"; + public static final String EXTRA_STATUS_MESSAGE = "extra_status_message"; + + private static final String CHANNEL_ID = "LibreCastCaptureChannel"; + private static final int NOTIFICATION_ID = 1001; + + private static boolean isRunning = false; + + private MediaProjection mediaProjection; + private MediaCodec encoder; + private VirtualDisplay virtualDisplay; + private LibreCastClient client; + private Thread encoderThread; + + private volatile boolean isEncoding = false; + + public static boolean isCasting() { + return isRunning; + } + + @Override + public IBinder onBind(Intent intent) { + return null; + } + + @Override + public void onCreate() { + super.onCreate(); + createNotificationChannel(); + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + if (intent != null && intent.getAction() != null) { + String action = intent.getAction(); + if (ACTION_START.equals(action)) { + int resultCode = intent.getIntExtra(EXTRA_RESULT_CODE, 0); + Intent resultData = intent.getParcelableExtra(EXTRA_RESULT_DATA); + String targetUri = intent.getStringExtra(EXTRA_TARGET_URI); + + if (resultCode != 0 && resultData != null && targetUri != null) { + startForegroundWithNotification(); + startCasting(resultCode, resultData, targetUri); + } else { + Log.e(TAG, "Invalid parameters for starting cast"); + stopSelf(); + } + } else if (ACTION_STOP.equals(action)) { + stopCasting(); + } + } + return START_NOT_STICKY; + } + + private void startForegroundWithNotification() { + Notification notification = new NotificationCompat.Builder(this, CHANNEL_ID) + .setContentTitle("LibreCast") + .setContentText("Screen casting in progress...") + .setSmallIcon(android.R.drawable.ic_menu_camera) + .setPriority(NotificationCompat.PRIORITY_LOW) + .build(); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION); + } else { + startForeground(NOTIFICATION_ID, notification); + } + } + + private void createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + NotificationChannel channel = new NotificationChannel( + CHANNEL_ID, + "LibreCast Screen Capture", + NotificationManager.IMPORTANCE_LOW + ); + NotificationManager manager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); + if (manager != null) { + manager.createNotificationChannel(channel); + } + } + } + + private synchronized void startCasting(int resultCode, Intent resultData, String targetUriStr) { + if (isRunning) { + return; + } + isRunning = true; + notifyStatus(true, "Connecting to target TV..."); + + try { + URI uri = new URI(targetUriStr); + client = new LibreCastClient(uri, this); + client.connect(); + + MediaProjectionManager projectionManager = (MediaProjectionManager) getSystemService(Context.MEDIA_PROJECTION_SERVICE); + if (projectionManager != null) { + mediaProjection = projectionManager.getMediaProjection(resultCode, resultData); + } + + if (mediaProjection == null) { + Log.e(TAG, "Failed to obtain MediaProjection"); + stopCasting(); + return; + } + + setupEncoderAndVirtualDisplay(); + + } catch (Exception e) { + Log.e(TAG, "Error starting cast", e); + stopCasting(); + } + } + + private void setupEncoderAndVirtualDisplay() { + try { + DisplayMetrics metrics = getResources().getDisplayMetrics(); + int width = 1280; + int height = 720; + if (metrics.widthPixels < metrics.heightPixels) { + width = 720; + height = 1280; + } + int densityDpi = metrics.densityDpi > 0 ? metrics.densityDpi : DisplayMetrics.DENSITY_HIGH; + + MediaFormat format = MediaFormat.createVideoFormat("video/avc", width, height); + format.setInteger(MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface); + format.setInteger(MediaFormat.KEY_BIT_RATE, 3000000); // 3 Mbps + format.setInteger(MediaFormat.KEY_FRAME_RATE, 30); // 30 fps + format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 1); // 1 sec + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + format.setInteger(MediaFormat.KEY_PROFILE, MediaCodecInfo.CodecProfileLevel.AVCProfileBaseline); + format.setInteger(MediaFormat.KEY_LEVEL, MediaCodecInfo.CodecProfileLevel.AVCLevel31); + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + format.setInteger(MediaFormat.KEY_LATENCY, 0); + } + + encoder = MediaCodec.createEncoderByType("video/avc"); + encoder.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE); + Surface inputSurface = encoder.createInputSurface(); + encoder.start(); + + virtualDisplay = mediaProjection.createVirtualDisplay( + "LibreCastCapture", + width, height, densityDpi, + DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR, + inputSurface, + null, null + ); + + isEncoding = true; + encoderThread = new Thread(new Runnable() { + @Override + public void run() { + encoderLoop(); + } + }, "ScreenCaptureEncoderThread"); + encoderThread.start(); + + } catch (Exception e) { + Log.e(TAG, "Failed to setup MediaCodec encoder or VirtualDisplay", e); + stopCasting(); + } + } + + private void encoderLoop() { + ByteBuffer[] outputBuffers = null; + try { + outputBuffers = encoder.getOutputBuffers(); + } catch (Exception e) { + Log.e(TAG, "Error getting output buffers", e); + return; + } + + MediaCodec.BufferInfo bufferInfo = new MediaCodec.BufferInfo(); + + while (isEncoding && !Thread.currentThread().isInterrupted()) { + try { + int outputBufferIndex = encoder.dequeueOutputBuffer(bufferInfo, 10000); // 10ms + if (outputBufferIndex >= 0) { + if (bufferInfo.size > 0) { + ByteBuffer outBuffer = outputBuffers[outputBufferIndex]; + outBuffer.position(bufferInfo.offset); + outBuffer.limit(bufferInfo.offset + bufferInfo.size); + + byte[] data = new byte[bufferInfo.size]; + outBuffer.get(data); + + if (client != null && client.isOpen()) { + client.sendFrame(data); + } + } + encoder.releaseOutputBuffer(outputBufferIndex, false); + } else if (outputBufferIndex == MediaCodec.INFO_OUTPUT_BUFFERS_CHANGED) { + outputBuffers = encoder.getOutputBuffers(); + } else if (outputBufferIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) { + MediaFormat newFormat = encoder.getOutputFormat(); + Log.d(TAG, "Encoder format changed: " + newFormat); + if (newFormat.containsKey("csd-0")) { + ByteBuffer sps = newFormat.getByteBuffer("csd-0"); + if (sps != null && client != null && client.isOpen()) { + byte[] spsBytes = new byte[sps.remaining()]; + sps.get(spsBytes); + sps.rewind(); + client.sendFrame(spsBytes); + } + } + if (newFormat.containsKey("csd-1")) { + ByteBuffer pps = newFormat.getByteBuffer("csd-1"); + if (pps != null && client != null && client.isOpen()) { + byte[] ppsBytes = new byte[pps.remaining()]; + pps.get(ppsBytes); + pps.rewind(); + client.sendFrame(ppsBytes); + } + } + } + } catch (Exception e) { + if (isEncoding) { + Log.e(TAG, "Error during encoding loop", e); + } + break; + } + } + } + + private synchronized void stopCasting() { + isEncoding = false; + if (encoderThread != null) { + encoderThread.interrupt(); + try { + encoderThread.join(500); + } catch (InterruptedException ignored) { + } + encoderThread = null; + } + + if (virtualDisplay != null) { + try { + virtualDisplay.release(); + } catch (Exception ignored) { + } + virtualDisplay = null; + } + + if (encoder != null) { + try { + encoder.stop(); + } catch (Exception ignored) { + } + try { + encoder.release(); + } catch (Exception ignored) { + } + encoder = null; + } + + if (mediaProjection != null) { + try { + mediaProjection.stop(); + } catch (Exception ignored) { + } + mediaProjection = null; + } + + if (client != null) { + try { + client.close(); + } catch (Exception ignored) { + } + client = null; + } + + isRunning = false; + notifyStatus(false, "Cast stopped"); + stopForeground(true); + stopSelf(); + } + + private void notifyStatus(boolean isCasting, String message) { + Intent intent = new Intent(ACTION_STATUS_CHANGED); + intent.putExtra(EXTRA_IS_CASTING, isCasting); + intent.putExtra(EXTRA_STATUS_MESSAGE, message); + sendBroadcast(intent); + } + + // LibreCastClient.ClientListener + + @Override + public void onConnected() { + Log.i(TAG, "Connected to receiver WebSocket"); + notifyStatus(true, "Connected! Streaming screen..."); + } + + @Override + public void onDisconnected() { + Log.i(TAG, "Disconnected from receiver WebSocket"); + stopCasting(); + } + + @Override + public void onError(Exception ex) { + Log.e(TAG, "LibreCastClient error", ex); + notifyStatus(isRunning, "WebSocket error: " + ex.getMessage()); + } + + @Override + public void onDestroy() { + stopCasting(); + super.onDestroy(); + } +} diff --git a/app/src/main/java/com/m5dev/librecast/SenderActivity.java b/app/src/main/java/com/m5dev/librecast/SenderActivity.java new file mode 100644 index 0000000..5de94ad --- /dev/null +++ b/app/src/main/java/com/m5dev/librecast/SenderActivity.java @@ -0,0 +1,220 @@ +package com.m5dev.librecast; + +import android.Manifest; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.DialogInterface; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.pm.PackageManager; +import android.media.projection.MediaProjectionManager; +import android.os.Build; +import android.os.Bundle; +import android.view.View; +import android.widget.Button; +import android.widget.EditText; +import android.widget.TextView; +import android.widget.Toast; +import androidx.annotation.NonNull; +import androidx.appcompat.app.AlertDialog; +import androidx.appcompat.app.AppCompatActivity; +import androidx.core.app.ActivityCompat; +import androidx.core.content.ContextCompat; + +import com.google.zxing.integration.android.IntentIntegrator; +import com.google.zxing.integration.android.IntentResult; + +public class SenderActivity extends AppCompatActivity { + + private static final int REQUEST_CODE_SCREEN_CAPTURE = 2001; + private static final int REQUEST_CODE_CAMERA_PERMISSION = 2002; + + private EditText editTargetAddress; + private Button btnScanQr; + private Button btnToggleCast; + private TextView textCastStatus; + + private final BroadcastReceiver statusReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + if (intent != null && ScreenCaptureService.ACTION_STATUS_CHANGED.equals(intent.getAction())) { + boolean isCasting = intent.getBooleanExtra(ScreenCaptureService.EXTRA_IS_CASTING, false); + String statusMsg = intent.getStringExtra(ScreenCaptureService.EXTRA_STATUS_MESSAGE); + updateUIState(isCasting, statusMsg); + } + } + }; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_sender); + + editTargetAddress = findViewById(R.id.edit_target_address); + btnScanQr = findViewById(R.id.btn_scan_qr); + btnToggleCast = findViewById(R.id.btn_toggle_cast); + textCastStatus = findViewById(R.id.text_cast_status); + + // Check compatibility for screen capture (< Android 5.0) + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { + showUnsupportedVersionDialog(); + } + + btnScanQr.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + checkCameraPermissionAndScan(); + } + }); + + btnToggleCast.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + if (ScreenCaptureService.isCasting()) { + stopCastService(); + } else { + startCastService(); + } + } + }); + + updateUIState(ScreenCaptureService.isCasting(), null); + } + + @Override + protected void onResume() { + super.onResume(); + IntentFilter filter = new IntentFilter(ScreenCaptureService.ACTION_STATUS_CHANGED); + registerReceiver(statusReceiver, filter); + updateUIState(ScreenCaptureService.isCasting(), null); + } + + @Override + protected void onPause() { + super.onPause(); + try { + unregisterReceiver(statusReceiver); + } catch (Exception ignored) { + } + } + + private void showUnsupportedVersionDialog() { + new AlertDialog.Builder(this) + .setTitle(R.string.unsupported_version_title) + .setMessage(R.string.unsupported_version_message) + .setPositiveButton(R.string.btn_ok, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + finish(); + } + }) + .setCancelable(false) + .show(); + } + + private void checkCameraPermissionAndScan() { + if (!getPackageManager().hasSystemFeature(PackageManager.FEATURE_CAMERA_ANY)) { + Toast.makeText(this, "Camera not available on this device", Toast.LENGTH_SHORT).show(); + return; + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED) { + ActivityCompat.requestPermissions(this, new String[]{Manifest.permission.CAMERA}, REQUEST_CODE_CAMERA_PERMISSION); + return; + } + } + startQRScanner(); + } + + private void startQRScanner() { + IntentIntegrator integrator = new IntentIntegrator(this); + integrator.setPrompt("Scan TV QR Code"); + integrator.setOrientationLocked(false); + integrator.setBeepEnabled(true); + integrator.initiateScan(); + } + + @Override + public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults); + if (requestCode == REQUEST_CODE_CAMERA_PERMISSION) { + if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) { + startQRScanner(); + } else { + Toast.makeText(this, R.string.camera_permission_required, Toast.LENGTH_SHORT).show(); + } + } + } + + private void startCastService() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { + showUnsupportedVersionDialog(); + return; + } + + String targetAddress = editTargetAddress.getText().toString().trim(); + if (targetAddress.isEmpty() || (!targetAddress.startsWith("ws://") && !targetAddress.startsWith("wss://"))) { + Toast.makeText(this, R.string.invalid_address, Toast.LENGTH_LONG).show(); + return; + } + + MediaProjectionManager projectionManager = (MediaProjectionManager) getSystemService(Context.MEDIA_PROJECTION_SERVICE); + if (projectionManager != null) { + startActivityForResult(projectionManager.createScreenCaptureIntent(), REQUEST_CODE_SCREEN_CAPTURE); + } + } + + private void stopCastService() { + Intent serviceIntent = new Intent(this, ScreenCaptureService.class); + serviceIntent.setAction(ScreenCaptureService.ACTION_STOP); + startService(serviceIntent); + } + + @Override + protected void onActivityResult(int requestCode, int resultCode, Intent data) { + if (requestCode == REQUEST_CODE_SCREEN_CAPTURE) { + if (resultCode == RESULT_OK && data != null) { + String targetAddress = editTargetAddress.getText().toString().trim(); + Intent serviceIntent = new Intent(this, ScreenCaptureService.class); + serviceIntent.setAction(ScreenCaptureService.ACTION_START); + serviceIntent.putExtra(ScreenCaptureService.EXTRA_RESULT_CODE, resultCode); + serviceIntent.putExtra(ScreenCaptureService.EXTRA_RESULT_DATA, data); + serviceIntent.putExtra(ScreenCaptureService.EXTRA_TARGET_URI, targetAddress); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + startForegroundService(serviceIntent); + } else { + startService(serviceIntent); + } + } else { + Toast.makeText(this, "Screen capture permission denied", Toast.LENGTH_SHORT).show(); + } + } else { + IntentResult result = IntentIntegrator.parseActivityResult(requestCode, resultCode, data); + if (result != null) { + if (result.getContents() != null) { + editTargetAddress.setText(result.getContents()); + } + } else { + super.onActivityResult(requestCode, resultCode, data); + } + } + } + + private void updateUIState(boolean isCasting, String statusMsg) { + if (isCasting) { + btnToggleCast.setText(R.string.btn_stop_cast); + btnToggleCast.setTextColor(getResources().getColor(R.color.accent_red)); + editTargetAddress.setEnabled(false); + btnScanQr.setEnabled(false); + textCastStatus.setText(statusMsg != null ? statusMsg : getString(R.string.casting_active)); + } else { + btnToggleCast.setText(R.string.btn_start_cast); + btnToggleCast.setTextColor(getResources().getColor(R.color.accent_green)); + editTargetAddress.setEnabled(true); + btnScanQr.setEnabled(true); + textCastStatus.setText(statusMsg != null ? statusMsg : getString(R.string.casting_stopped)); + } + } +} diff --git a/app/src/main/res/drawable/button_focused.xml b/app/src/main/res/drawable/button_focused.xml new file mode 100644 index 0000000..a52846d --- /dev/null +++ b/app/src/main/res/drawable/button_focused.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/app/src/main/res/drawable/button_normal.xml b/app/src/main/res/drawable/button_normal.xml new file mode 100644 index 0000000..cb6324e --- /dev/null +++ b/app/src/main/res/drawable/button_normal.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/app/src/main/res/drawable/button_selector.xml b/app/src/main/res/drawable/button_selector.xml new file mode 100644 index 0000000..90ec8e0 --- /dev/null +++ b/app/src/main/res/drawable/button_selector.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/drawable/input_field_bg.xml b/app/src/main/res/drawable/input_field_bg.xml new file mode 100644 index 0000000..6f215e5 --- /dev/null +++ b/app/src/main/res/drawable/input_field_bg.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/app/src/main/res/layout/activity_home.xml b/app/src/main/res/layout/activity_home.xml new file mode 100644 index 0000000..eabb42b --- /dev/null +++ b/app/src/main/res/layout/activity_home.xml @@ -0,0 +1,71 @@ + + + + + + + + + + + + +