commit f6419500da6c820fb8b15be1be2ca4e4cd357898 Author: kashamannaya Date: Sun Jun 8 02:48:00 2025 +0300 Initial commit for the Lilula app - MVP stage: At this stage: - The program launches the rear camera of the smartphone (if 1 camera is available, it uses it). - Allows you to take a photo and save it on the phone: Pictures/Lilula - Allows you to broadcast video on the local network to the IP address of the smartphone in Mjpeg format, which can be viewed by any client: browser, VLC, etc. The broadcast address is shown on the screen. API level 19+ (Android 4.4.+) Tested on devices Android 4.4.2, Android 13 (Color OS), Android 14 (LineagoOS) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cf634e2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,64 @@ +*.iml + +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +/build +/captures +.externalNativeBuild +.cxx + +# IntelliJ +out/ + + +# Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser + +# default output directories +classes/ +deploy/ +javadoc/ + +##### Gradle +.gradle +**/build/ +!src/**/build/ + +# Local configuration file (sdk path, etc) +local.properties + +# Log/OS Files +*.log + +# Android Studio generated files and folders +captures/ +.externalNativeBuild/ +.cxx/ +*.apk +output.json + +# IntelliJ +*.iml +.idea/ +misc.xml +deploymentTargetDropDown.xml +render.experimental.xml + +# Keystore files +*.jks +*.keystore + +# Google Services (e.g. APIs or Firebase) +google-services.json + +# Android Profiling +*.hprof + +### Android Patch ### +gen-external-apklibs \ No newline at end of file diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..26d3352 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/.idea/AndroidProjectSystem.xml b/.idea/AndroidProjectSystem.xml new file mode 100644 index 0000000..4a53bee --- /dev/null +++ b/.idea/AndroidProjectSystem.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/compiler.xml b/.idea/compiler.xml new file mode 100644 index 0000000..b86273d --- /dev/null +++ b/.idea/compiler.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml new file mode 100644 index 0000000..d9a9c1a --- /dev/null +++ b/.idea/deploymentTargetSelector.xml @@ -0,0 +1,18 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml new file mode 100644 index 0000000..639c779 --- /dev/null +++ b/.idea/gradle.xml @@ -0,0 +1,19 @@ + + + + + + + \ No newline at end of file diff --git a/.idea/migrations.xml b/.idea/migrations.xml new file mode 100644 index 0000000..f8051a6 --- /dev/null +++ b/.idea/migrations.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..74dd639 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,10 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/runConfigurations.xml b/.idea/runConfigurations.xml new file mode 100644 index 0000000..16660f1 --- /dev/null +++ b/.idea/runConfigurations.xml @@ -0,0 +1,17 @@ + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..a320086 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,49 @@ +plugins { + alias(libs.plugins.android.application) +} + +android { + namespace = "com.gitea.kashamannaya.lilula" + compileSdk = 35 + + defaultConfig { + applicationId = "com.gitea.kashamannaya.lilula" + minSdk = 19 + targetSdk = 35 + versionCode = 1 + versionName = "1.0" + multiDexEnabled =true + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } +} + +dependencies { + +// https://mvnrepository.com/artifact/androidx.appcompat/appcompat + runtimeOnly(libs.appcompat.v161) + implementation(libs.material) +// https://mvnrepository.com/artifact/androidx.activity/activity + runtimeOnly(libs.activity.v190) + implementation (libs.multidex) + implementation(libs.rxandroid) + implementation(libs.rxjava) + implementation (libs.androidasync) + + testImplementation(libs.junit) + androidTestImplementation(libs.ext.junit) + androidTestImplementation(libs.espresso.core) +} \ No newline at end of file diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/app/src/androidTest/java/com/gitea/kashamannaya/lilula/ExampleInstrumentedTest.java b/app/src/androidTest/java/com/gitea/kashamannaya/lilula/ExampleInstrumentedTest.java new file mode 100644 index 0000000..a6ce5eb --- /dev/null +++ b/app/src/androidTest/java/com/gitea/kashamannaya/lilula/ExampleInstrumentedTest.java @@ -0,0 +1,26 @@ +package com.gitea.kashamannaya.lilula; + +import android.content.Context; + +import androidx.test.platform.app.InstrumentationRegistry; +import androidx.test.ext.junit.runners.AndroidJUnit4; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import static org.junit.Assert.*; + +/** + * Instrumented test, which will execute on an Android device. + * + * @see Testing documentation + */ +@RunWith(AndroidJUnit4.class) +public class ExampleInstrumentedTest { + @Test + public void useAppContext() { + // Context of the app under test. + Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext(); + assertEquals("com.gitea.kashamannaya.lilula", appContext.getPackageName()); + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..5514c14 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/ic_launcher-playstore.png b/app/src/main/ic_launcher-playstore.png new file mode 100644 index 0000000..c11802a Binary files /dev/null and b/app/src/main/ic_launcher-playstore.png differ diff --git a/app/src/main/java/com/gitea/kashamannaya/lilula/MainActivity.java b/app/src/main/java/com/gitea/kashamannaya/lilula/MainActivity.java new file mode 100644 index 0000000..738af2c --- /dev/null +++ b/app/src/main/java/com/gitea/kashamannaya/lilula/MainActivity.java @@ -0,0 +1,537 @@ +package com.gitea.kashamannaya.lilula; + +import android.Manifest; +import android.annotation.SuppressLint; +import android.content.Context; +import android.content.pm.PackageManager; +import android.content.res.Configuration; +import android.graphics.ImageFormat; +import android.graphics.Rect; +import android.graphics.YuvImage; +import android.hardware.Camera; +import android.os.Build; +import android.os.Bundle; +import android.os.Environment; +import android.os.Handler; +import android.util.Log; +import android.view.Surface; +import android.view.SurfaceHolder; +import android.view.SurfaceView; +import android.view.WindowManager; +import android.widget.Button; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.AppCompatActivity; +import androidx.core.app.ActivityCompat; +import androidx.core.content.ContextCompat; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.List; +import java.util.concurrent.TimeUnit; + +import io.reactivex.Observable; +import io.reactivex.disposables.Disposable; +import io.reactivex.schedulers.Schedulers; + +public class MainActivity extends AppCompatActivity implements SurfaceHolder.Callback { + + private static final int CAMERA_REQUEST_CODE = 100; + private static final String KEY_CAMERA_ID = "camera_id"; + private static final String KEY_PREVIEW_STATE = "preview_state"; + + private Camera camera; + private SurfaceView surfaceView; + private SurfaceHolder surfaceHolder; + private Button captureButton; + private Button toggleButton; + private boolean isPreviewRunning = false; + private boolean isSurfaceReady = false; + private int cameraId = -1; + private TextView uRLServerLabel; + + private MjpegServer mjpegServer; + private static final int STREAM_PORT = 8080; + private boolean isStreaming = false; + private Disposable frameDisposable; + + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_main); + keepScreenOn(); + + // ) инициализируем сервер + mjpegServer = new MjpegServer(STREAM_PORT); + // Восстановление cameraId при повороте + if (savedInstanceState != null) { + cameraId = savedInstanceState.getInt(KEY_CAMERA_ID, -1); + isPreviewRunning = savedInstanceState.getBoolean(KEY_PREVIEW_STATE, false); + } else { + // Находим доступную камеру только при первом создании + cameraId = findAvailableCamera(); + } + + surfaceView = findViewById(R.id.surface_view); + captureButton = findViewById(R.id.capture_button); + toggleButton = findViewById(R.id.toggle_button); + uRLServerLabel = findViewById(R.id.url_server); + surfaceHolder = surfaceView.getHolder(); + surfaceHolder.addCallback(this); + surfaceHolder.setType(SurfaceHolder.SURFACE_TYPE_PUSH_BUFFERS); + + // Проверка наличия камеры + if (cameraId < 0) { + Toast.makeText(this, getResources().getString(R.string.toast_text_error_camera_not_found), Toast.LENGTH_LONG).show(); + finish(); + return; + } + + captureButton.setOnClickListener(v -> captureImage()); + toggleButton.setOnClickListener(v -> togglePreview()); + } + + private void keepScreenOn() { + getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); + } + + private void updateToggleButtonText() { + runOnUiThread(() -> { + if (toggleButton != null) { + String text = isPreviewRunning ? getResources().getString(R.string.text_on_button_video_stream_stop) : getResources().getString(R.string.text_on_button_video_stream_start); + + toggleButton.setText(text); + } + }); + } + + @Override + protected void onSaveInstanceState(@NonNull Bundle outState) { + super.onSaveInstanceState(outState); + outState.putInt(KEY_CAMERA_ID, cameraId); + outState.putBoolean(KEY_PREVIEW_STATE, isPreviewRunning); + } + + + private int findAvailableCamera() { + + int numberOfCameras = Camera.getNumberOfCameras(); + // Если камер нет вообще + if (numberOfCameras == 0) { + return -1; + } + // Если камера только одна - используем её + if (numberOfCameras == 1) { + return 0; + } + + + // Если камер несколько - ищем заднюю + int backCameraId = -1; + for (int i = 0; i < numberOfCameras; i++) { + Camera.CameraInfo info = new Camera.CameraInfo(); + try { + Camera.getCameraInfo(i, info); + if (info.facing == Camera.CameraInfo.CAMERA_FACING_BACK) { + return i; // Нашли заднюю камеру + } + // Запоминаем первую камеру на случай, если задней нет + if (backCameraId == -1) { + backCameraId = i; + } + } catch (RuntimeException e) { +// Log.e("Camera", "Ошибка получения информации о камере " + i, e); + } + } + + // Если не нашли заднюю, но есть другие камеры - используем первую доступную + return backCameraId; + + } + + @Override + protected void onResume() { + super.onResume(); + + if (checkCameraPermission()) { + startCamera(); + } + // Гарантированное обновление состояния кнопки + updateToggleButtonText(); + } + + @Override + protected void onPause() { + super.onPause(); + stopStreaming(); + stopCamera(); + } + + + private boolean checkCameraPermission() { + 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}, + CAMERA_REQUEST_CODE); + return false; + } + } + return true; + } + + @Override + public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults); + if (requestCode == CAMERA_REQUEST_CODE) { + if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) { + startCamera(); + } else { + Toast.makeText(this, getResources().getString(R.string.toast_text_error_camera_not_permitted), Toast.LENGTH_SHORT).show(); + } + } + } + + private void startCamera() { + if (isSurfaceReady && !isPreviewRunning) { + safeCameraOpen(); + } + } + + private void stopCamera() { + if (camera != null) { + try { + camera.stopPreview(); + camera.release(); + } catch (Exception e) { +// Log.e("Camera", "Ошибка остановки камеры: " + e.getMessage()); + } + isPreviewRunning = false; + } + updateToggleButtonText(); + } + + private void togglePreview() { + if (isPreviewRunning) { + stopPreview(); + toggleButton.setText(R.string.text_on_button_video_stream_start); + } else { + startPreview(); + toggleButton.setText(R.string.text_on_button_video_stream_stop); + } + } + + private void startPreview() { + if (camera == null) { + safeCameraOpen(); + } + + if (camera != null) { + try { + camera.setPreviewDisplay(surfaceHolder); + camera.startPreview(); + isPreviewRunning = true; + updateToggleButtonText(); + // Запускаем трансляцию + startStreaming(); + } catch (IOException e) { +// Log.e("Camera", "Ошибка запуска предпросмотра: " + e.getMessage()); + } + } + + } + + private void stopPreview() { + if (camera != null) { + try { + camera.stopPreview(); + } catch (Exception e) { +// Log.e("Camera", "Ошибка остановки камеры: " + e.getMessage()); + } + isPreviewRunning = false; + + updateToggleButtonText(); + // Останавливаем трансляцию + stopStreaming(); + } + } + + // Добавим методы для управления трансляцией + @SuppressLint("LongLogTag") + private void startStreaming() { + if (isStreaming) return; + + // Проверяем подключение к Wi-Fi + if (!NetworkUtils.isWifiConnected(this)) { + Toast.makeText(this, getResources().getString(R.string.toast_text_error_wifi_not_available), Toast.LENGTH_LONG).show(); + return; + } + + mjpegServer.start(); + isStreaming = true; + + // Получаем и отображаем адрес трансляции + String streamUrl = mjpegServer.getStreamUrl(); + String text = getResources().getString(R.string.toast_text_video_stream_start); + Toast.makeText(this, text + "\n" + streamUrl, Toast.LENGTH_LONG).show(); + runOnUiThread(() -> uRLServerLabel.setText(streamUrl)); + // Начинаем захват кадров для трансляции + startFrameCapture(); +// Log.i("startStreaming: >>>>>>>>>>>>>>", "Трансляция запущена: " + streamUrl); + } + + private void stopStreaming() { + if (!isStreaming) return; + if (frameDisposable != null && !frameDisposable.isDisposed()) { + frameDisposable.dispose(); + } + mjpegServer.stop(); + isStreaming = false; + Toast.makeText(this, getResources().getString(R.string.toast_text_video_stream_stop), Toast.LENGTH_SHORT).show(); + } + + private void startFrameCapture() { + if (frameDisposable != null && !frameDisposable.isDisposed()) { + frameDisposable.dispose(); + } + + // Используем RxJava для захвата кадров в фоновом потоке + frameDisposable = Observable.interval(100, TimeUnit.MILLISECONDS) // 10 FPS + .subscribeOn(Schedulers.io()) + .observeOn(Schedulers.io()) + .subscribe(tick -> captureFrameForStreaming()); + } + + private void captureFrameForStreaming() { + if (camera == null || !isStreaming) return; + + camera.setOneShotPreviewCallback((data, camera) -> { + Camera.Size size = camera.getParameters().getPreviewSize(); + YuvImage yuvImage = new YuvImage(data, ImageFormat.NV21, size.width, size.height, null); + ByteArrayOutputStream out = new ByteArrayOutputStream(30 * 1024); + yuvImage.compressToJpeg(new Rect(0, 0, size.width, size.height), 60, out); + byte[] jpegBytes = out.toByteArray(); + + if (mjpegServer.isRunning()) { + mjpegServer.sendFrame(jpegBytes); + } + }); + } + + private void safeCameraOpen() { + try { + camera = Camera.open(cameraId); + setupCameraParameters(); + if (isSurfaceReady) { + startPreview(); + } + } catch (Exception e) { +// Log.e("Camera", "Не удалось открыть камеру: " + e.getMessage()); + // Повторная попытка через 500 мс + new Handler().postDelayed(() -> { + try { + camera = Camera.open(cameraId); + setupCameraParameters(); + if (isSurfaceReady) { + startPreview(); + } + } catch (Exception ex) { +// Log.e("Camera", "Повторная ошибка открытия камеры: " + ex.getMessage()); + } + }, 500); + } + } + + + private void setCameraDisplayOrientation() { + if (camera == null) return; + + int rotation = getWindowManager().getDefaultDisplay().getRotation(); + int degrees = 0; + + switch (rotation) { + case Surface.ROTATION_0: + degrees = 0; + break; + case Surface.ROTATION_90: + degrees = 90; + break; + case Surface.ROTATION_180: + degrees = 180; + break; + case Surface.ROTATION_270: + degrees = 270; + break; + } + + int result; + Camera.CameraInfo info = new Camera.CameraInfo(); + Camera.getCameraInfo(cameraId, info); + + if (info.facing == Camera.CameraInfo.CAMERA_FACING_FRONT) { + result = (info.orientation + degrees) % 360; + result = (360 - result) % 360; + } else { + result = (info.orientation - degrees + 360) % 360; + } + + camera.setDisplayOrientation(result); + } + + + private void setupCameraParameters() { + if (camera == null) return; + + try { + Camera.Parameters params = camera.getParameters(); + + // Установка ориентации + setCameraDisplayOrientation(); + + // Получение поддерживаемых размеров + List sizes = params.getSupportedPreviewSizes(); + Camera.Size optimalSize = getOptimalPreviewSize(sizes, surfaceView.getWidth(), surfaceView.getHeight()); + if (optimalSize != null) { + params.setPreviewSize(optimalSize.width, optimalSize.height); + } + + // Настройка FPS + List fpsRanges = params.getSupportedPreviewFpsRange(); + if (fpsRanges != null && !fpsRanges.isEmpty()) { + int[] maxFps = fpsRanges.get(0); + for (int[] range : fpsRanges) { + if (range[1] > maxFps[1]) maxFps = range; + } + params.setPreviewFpsRange(maxFps[0], maxFps[1]); + } + // Автофокус + if (params.getSupportedFocusModes() != null && + params.getSupportedFocusModes().contains(Camera.Parameters.FOCUS_MODE_CONTINUOUS_PICTURE)) { + params.setFocusMode(Camera.Parameters.FOCUS_MODE_CONTINUOUS_PICTURE); + } + + camera.setParameters(params); + } catch (Exception e) { +// Log.e("Camera", "Ошибка настройки параметров: " + e.getMessage()); + } + } + + private Camera.Size getOptimalPreviewSize(List sizes, int w, int h) { + final double ASPECT_TOLERANCE = 0.1; + double targetRatio = (double) w / h; + + if (sizes == null) return null; + + Camera.Size optimalSize = null; + double minDiff = Double.MAX_VALUE; + + for (Camera.Size size : sizes) { + double ratio = (double) size.width / size.height; + if (Math.abs(ratio - targetRatio) > ASPECT_TOLERANCE) continue; + if (Math.abs(size.height - h) < minDiff) { + optimalSize = size; + minDiff = Math.abs(size.height - h); + } + } + + if (optimalSize == null) { + for (Camera.Size size : sizes) { + if (Math.abs(size.height - h) < minDiff) { + optimalSize = size; + minDiff = Math.abs(size.height - h); + } + } + } + return optimalSize; + } + + private void captureImage() { + if (camera == null) return; + + camera.takePicture(null, null, (data, camera) -> { + saveImage(data); + if (isPreviewRunning) { + camera.startPreview(); + } + }); + } + + private void saveImage(byte[] data) { + File pictureFileDir = new File( + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES), + getResources().getString(R.string.app_name) + ); + + if (!pictureFileDir.exists() && !pictureFileDir.mkdirs()) { +// Log.e("Camera", "Не удалось создать директорию"); + return; + } + + String timeStamp = new SimpleDateFormat("yyyyMMdd_HHmmss").format(new Date()); + String fileName = pictureFileDir.getPath() + File.separator + "IMG_" + timeStamp + ".jpg"; + + try (FileOutputStream fos = new FileOutputStream(fileName)) { + fos.write(data); + Toast.makeText(this, getResources().getString(R.string.toast_text_snapshot_saved) + fileName, Toast.LENGTH_SHORT).show(); + } catch (Exception e) { +// Log.e("Camera", "Ошибка сохранения фото: " + e.getMessage()); + } + } + + @Override + public void surfaceCreated(@NonNull SurfaceHolder holder) { + isSurfaceReady = true; + startCamera(); + updateToggleButtonText(); + } + + @Override + public void surfaceChanged(@NonNull SurfaceHolder holder, int format, int width, int height) { + if (camera != null && isPreviewRunning) { + try { + camera.stopPreview(); + setupCameraParameters(); + camera.setPreviewDisplay(holder); + camera.startPreview(); + } catch (IOException e) { +// Log.e("Camera", "Ошибка изменения Surface: " + e.getMessage()); + } + } + } + + @Override + public void surfaceDestroyed(@NonNull SurfaceHolder holder) { + isSurfaceReady = false; + stopCamera(); + getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); + } + + @Override + public void onConfigurationChanged(@NonNull Configuration newConfig) { + super.onConfigurationChanged(newConfig); + + // Перезапускаем превью при изменении конфигурации + if (camera != null && isPreviewRunning) { + new Handler().postDelayed(() -> { + if (surfaceHolder.getSurface().isValid()) { + try { + camera.stopPreview(); + setupCameraParameters(); + camera.setPreviewDisplay(surfaceHolder); + camera.startPreview(); + } catch (Exception e) { +// Log.e("Camera", "Ошибка при повороте: " + e.getMessage()); + } + } + }, 300); + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/gitea/kashamannaya/lilula/MjpegServer.java b/app/src/main/java/com/gitea/kashamannaya/lilula/MjpegServer.java new file mode 100644 index 0000000..a161ab1 --- /dev/null +++ b/app/src/main/java/com/gitea/kashamannaya/lilula/MjpegServer.java @@ -0,0 +1,133 @@ +package com.gitea.kashamannaya.lilula; + + + +import com.koushikdutta.async.AsyncServer; +import com.koushikdutta.async.ByteBufferList; +import com.koushikdutta.async.callback.CompletedCallback; +import com.koushikdutta.async.http.server.AsyncHttpServer; +import com.koushikdutta.async.http.server.AsyncHttpServerRequest; +import com.koushikdutta.async.http.server.AsyncHttpServerResponse; +import com.koushikdutta.async.http.server.HttpServerRequestCallback; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.ConcurrentLinkedQueue; + +public class MjpegServer { + + private static final int MAX_CLIENTS = 5; + private static final String BOUNDARY = "ThisIsTheBoundary"; + + private final AsyncHttpServer server = new AsyncHttpServer(); + private final ConcurrentLinkedQueue clients = new ConcurrentLinkedQueue<>(); + private boolean isRunning = false; + private final int port; + + public MjpegServer(int port) { + this.port = port; + } + + public void start() { + if (isRunning) return; + + server.get("/stream", new HttpServerRequestCallback() { + @Override + public void onRequest(AsyncHttpServerRequest request, AsyncHttpServerResponse response) { + // Устанавливаем правильные заголовки + response.getHeaders().set("Connection", "close"); + response.getHeaders().set("Cache-Control", "no-cache, private"); + response.getHeaders().set("Content-Type", + "multipart/x-mixed-replace; boundary=" + BOUNDARY); + + // Ограничение количества клиентов + if (clients.size() >= MAX_CLIENTS) { + response.code(503).end(); // Service Unavailable + return; + } + + // Добавляем клиента в список + clients.add(response); + + // Отправляем начальный заголовок + String initialHeader = "--" + BOUNDARY + "\r\n"; + response.write(new ByteBufferList(ByteBuffer.wrap(initialHeader.getBytes(StandardCharsets.UTF_8)))); + + // Обработка закрытия соединения + response.setClosedCallback(new CompletedCallback() { + @Override + public void onCompleted(Exception ex) { + clients.remove(response); + } + }); + } + }); + + server.listen(AsyncServer.getDefault(), port); + isRunning = true; + } + + public void stop() { + if (!isRunning) return; + + // Закрываем все соединения + for (AsyncHttpServerResponse client : clients) { + try { + client.end(); + } catch (Exception e) { + // Игнорируем ошибки закрытия + } + } + clients.clear(); + + // Останавливаем сервер + server.stop(); + AsyncServer.getDefault().stop(); + isRunning = false; + } + + public void sendFrame(byte[] jpegData) { + if (!isRunning || clients.isEmpty()) return; + + try { + // Формируем полный фрейм + ByteArrayOutputStream frameStream = new ByteArrayOutputStream(); + frameStream.write(("\r\n--" + BOUNDARY + "\r\n").getBytes()); + frameStream.write("Content-Type: image/jpeg\r\n".getBytes()); + frameStream.write(("Content-Length: " + jpegData.length + "\r\n\r\n").getBytes()); + frameStream.write(jpegData); + frameStream.write("\r\n".getBytes()); + + byte[] frame = frameStream.toByteArray(); + ByteBuffer buffer = ByteBuffer.wrap(frame); + ByteBufferList bufferList = new ByteBufferList(buffer); + + // Отправляем всем активным клиентам + for (AsyncHttpServerResponse client : clients) { + if (!client.isOpen()) { + clients.remove(client); + continue; + } + + try { + client.write(bufferList); + } catch (Exception e) { + // Удаляем клиента при ошибке + clients.remove(client); + } + } + } catch (IOException e) { + e.printStackTrace(); + } + } + + public String getStreamUrl() { + return "http://" + NetworkUtils.getLocalIpAddress() + ":" + port + "/stream"; + } + + public boolean isRunning() { + return isRunning; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/gitea/kashamannaya/lilula/NetworkUtils.java b/app/src/main/java/com/gitea/kashamannaya/lilula/NetworkUtils.java new file mode 100644 index 0000000..766e234 --- /dev/null +++ b/app/src/main/java/com/gitea/kashamannaya/lilula/NetworkUtils.java @@ -0,0 +1,41 @@ +package com.gitea.kashamannaya.lilula; + +import android.content.Context; +import android.net.ConnectivityManager; +import android.net.NetworkInfo; +import android.util.Log; + +import java.net.Inet4Address; +import java.net.InetAddress; +import java.net.NetworkInterface; +import java.net.SocketException; +import java.util.Enumeration; + +public class NetworkUtils { + + public static String getLocalIpAddress() { + try { + for (Enumeration en = NetworkInterface.getNetworkInterfaces(); en.hasMoreElements();) { + NetworkInterface intf = en.nextElement(); + for (Enumeration enumIpAddr = intf.getInetAddresses(); enumIpAddr.hasMoreElements();) { + InetAddress inetAddress = enumIpAddr.nextElement(); + if (!inetAddress.isLoopbackAddress() && inetAddress instanceof Inet4Address) { + return inetAddress.getHostAddress(); + } + } + } + } catch (SocketException ex) { + Log.e("NetworkUtils", "Error getting IP address", ex); + } + return "0.0.0.0"; + } + + public static boolean isWifiConnected(Context context) { + ConnectivityManager connManager = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); + if (connManager != null) { + NetworkInfo wifiInfo = connManager.getNetworkInfo(ConnectivityManager.TYPE_WIFI); + return wifiInfo != null && wifiInfo.isConnected(); + } + return false; + } +} \ No newline at end of file diff --git a/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/app/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 0000000..2b068d1 --- /dev/null +++ b/app/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..07d5da9 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..7a82dcc --- /dev/null +++ b/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,47 @@ + + + + + + + +