מפת המעבדות · מעבדת ההרשאות הקודמת
בסוף השיעור צילום דרך אפליקציית מצלמה נשמר בקובץ פרטי של האפליקציה, מוצג שוב אחרי סגירה ופתיחה, ונמחק בלחיצה. בחירת תמונה קיימת ממשיכה לעבוד דרך Photo Picker בלי גישה לכל הגלריה. אפשר להבחין בין תמונת preview קטנה שלא נשמרת, תמונה שנבחרה דרך content://, ותמונה מלאה שהאפליקציה מחזיקה.
בסיס ההשוואה הוא codex/permission-contracts וענף התוצאה הוא codex/camera-gallery-storage בפרויקט topics. התחילו מן הענף הקודם, לא מ־master: הכפתורים וה־contracts הראשונים כבר קיימים.
שלושה מקורות, שלוש בעלויות
| פעולה | מי מחזיק בקובץ? | מה האפליקציה מקבלת? | האם שורד הפעלה מחדש? |
|---|---|---|---|
TakePicturePreview |
אין קובץ מובטח | Bitmap מוקטן בזיכרון |
לא |
PickVisualMedia |
ספק המדיה | URI והרשאת קריאה לפריט שנבחר | רק אם מתכננים גישה מתמשכת; כאן מציגים מיד |
TakePicture |
האפליקציה שלנו | Boolean; התמונה נכתבת ל־URI שנתנו מראש |
כן, כל עוד הקובץ נשמר |
ה־URI של ה־Photo Picker הוא מזהה גישה, לא מסלול שאפשר להמיר בבטחה ל־File. כך נשמרת הבחירה המצומצמת של המשתמש. לפי מדריך Photo Picker, בחירה נקודתית אינה דורשת הרשאת גישה רחבה לתמונות. בצילום המלא כאן אפליקציית המצלמה היא שמפעילה את החומרה; הפרויקט אינו מבקש CAMERA או הרשאת אחסון. אם עוברים בעתיד ל־CameraX בתוך האפליקציה, מדיניות ההרשאה שונה.
צילום הוא פעולה בשני שלבים
לפני פתיחת המצלמה יש לנו יעד אפשרי, לא תמונה שמורה. pendingPhotoName מזהה את קובץ הניסיון; ההעדפה photo מזהה את התמונה שהושלמה. רק אחרי תוצאת true ובדיקת תוכן מעדכנים את ההעדפה. ביטול מוחק את הניסיון החדש, ומשאיר את התמונה הקודמת. הפרדה זו מונעת מחיקת תמונה טובה רק משום שהמשתמש פתח מצלמה והתחרט.
stateDiagram-v2
[*] --> PreviousOrNone
PreviousOrNone --> Pending: allocate new name and URI
Pending --> PreviousOrNone: cancel or empty file, discard pending
Pending --> Saved: successful result and nonempty file
Saved --> Pending: start another capture
Saved --> NoSavedPhoto: confirmed file deletion
שמרו את השם הממתין ב־Bundle כי Android יכולה ליצור Activity חדשה בזמן שאפליקציית המצלמה בחזית. אל תשמרו בה Bitmap: Bundle הוא מנגנון מצב קטן ומוגבל, לא מיכל של מיליוני פיקסלים. URI זמני מספק למצלמה גישה מצומצמת ליעד דרך FileProvider בלי לחשוף את תיקיית הקבצים כולה.
בקירוב, Bitmap בגודל 4000×3000 עם ארבעה בתים לפיקסל דורש כ־48 מיליון בתים, גם אם JPEG מכווץ קטן בהרבה. inJustDecodeBounds קוראת מידות בלי להקצות את כל הפיקסלים, ו־inSampleSize מצמצמת את הפענוח לתצוגה. EXIF הוא metadata שמסביר כיוון; לעיתים דרוש גם mirror ולא רק סיבוב. גודל פיקסלים, גודל קובץ ומידות ImageView הם שלוש כמויות שונות.
גם פענוח ברקע יכול להחזיר תשובה מאוחרת: המשתמש מחק תמונה או בחר אחרת בזמן העבודה. יש לבדוק זהות/דור לפני פרסום Bitmap, כמו בבקשת רשת. הצלחת מחיקה צריכה להתקבל מן הקובץ; הסרת ההעדפה לפני File.delete מוצלחת תסתיר קובץ שנותר בפועל. במוצר הגדירו גם טיפול בכשל ובקבצים יתומים אחרי הפסקת תהליך באמצע צילום.
עצרו ונבאו
A היא התמונה השמורה. פתחתם צילום B וביטלתם במצלמה. מה צריכה ההעדפה המקומית להזכיר? כתבו תחזית לפני פתיחת ההסבר, ואז הצביעו על המשתנה או התנאי בקוד שמצדיקים אותה.
בדיקת ההבנה
את A. שם pending הוא יעד ניסיון שטרם הושלם, ולכן אינו מחליף את שם התמונה השמורה בתחילת הצילום. רק תשובה מוצלחת וקובץ לא ריק מעבירים את הבעלות לתמונה החדשה; ביטול משליך את הניסיון.
1. חושפים רק תיקייה אחת למצלמה
ב־app > res > xml צרו file_paths.xml:
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<files-path name="photos" path="photos/" />
</paths>
ב־app > manifests > AndroidManifest.xml, בתוך <application> ולפני <activity>, הוסיפו:
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.files"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
FileProvider ממיר קובץ פרטי ל־content:// ומאפשר גישה זמנית לאפליקציית המצלמה. ה־authority חייב להתאים לקריאה בקוד. ההגדרה אינה חושפת את כל files/, אלא רק את files/photos/. קראו גם את תיעוד FileProvider על התאמה בין הנתיב, ה־URI והרשאת הגישה.
2. מוסיפים תצוגה ופעולות
ב־app > res > layout > activity_main.xml, אחרי כפתור camera_preview ולפני result, הוסיפו שני Button עם המזהים take_picture ו־delete_photo ו־ImageView id=image. לשני הכפתורים רוחב match_parent, גובה wrap_content; ל־ImageView רוחב match_parent, גובה 220dp, scaleType="fitCenter", ותיאור נגישות @string/photo_preview_description. השאירו את הכפתורים הקיימים. ב־app > res > values > strings.xml הוסיפו:
<string name="take_picture">Take full photo</string>
<string name="delete_photo">Delete saved photo</string>
<string name="photo_preview_description">Selected or captured photo</string>
<string name="saved_photo">Saved photo: %1$d bytes, %2$d × %3$d pixels, EXIF rotation %4$d°.</string>
<string name="photo_missing">Saved photo is missing or unreadable.</string>
<string name="photo_deleted">Saved photo deleted.</string>
3. מייצרים URI לפני פתיחת המצלמה
ב־MainActivity.java הוסיפו את ה־imports ל־File, UUID, FileProvider, ActivityResultLauncher ו־Uri. הוסיפו שדה pendingPhotoName ו־launcher חדש לצד ארבעת ה־launchers הקודמים:
private String pendingPhotoName;
private final ActivityResultLauncher<Uri> takePicture = registerForActivityResult(
new ActivityResultContracts.TakePicture(), saved -> {
if (pendingPhotoName == null) return;
File photo = new File(photoDirectory(), pendingPhotoName);
if (saved && photo.length() > 0) {
String previous = getPreferences(MODE_PRIVATE).getString("photo", null);
getPreferences(MODE_PRIVATE).edit().putString("photo", pendingPhotoName).apply();
if (previous != null && !previous.equals(pendingPhotoName)) {
new File(photoDirectory(), previous).delete();
}
showSavedPhoto(photo);
} else {
// Cancellation discards only this attempt, never the previous saved photo.
photo.delete();
binding.result.setText(R.string.selection_cancelled);
}
pendingPhotoName = null;
});
ה־contract הרשמי מקבל URI ומחזיר true אם התמונה נשמרה. כאן בודקים גם שקיים תוכן, כך שלא מציגים קובץ ריק. ביטול מוחק רק את הקובץ החדש והזמני; התמונה הקודמת נשארת. עם צילום חדש מוצלח מוחקים את הקודם כדי שלא יצטברו קבצים פרטיים ללא גבול.
בתוך onCreate, מיד אחרי setContentView, שחזרו את השם הזמני מ־savedInstanceState, ולצד חיבורי הכפתורים הקיימים הוסיפו:
if (savedInstanceState != null) pendingPhotoName = savedInstanceState.getString("pending_photo");
binding.takePicture.setOnClickListener(view -> captureFullPhoto());
binding.deletePhoto.setOnClickListener(view -> deleteSavedPhoto());
String savedPhoto = getPreferences(MODE_PRIVATE).getString("photo", null);
if (savedPhoto != null) showSavedPhoto(new File(photoDirectory(), savedPhoto));
הוסיפו את השיטות הבאות ל־Activity:
/**
* Saves the pending capture identity while another Activity may be using the camera.
*
* @param outState small restoration Bundle; no Bitmap is stored here
*/
@Override
protected void onSaveInstanceState(Bundle outState) {
outState.putString("pending_photo", pendingPhotoName);
super.onSaveInstanceState(outState);
}
/**
* Returns this app's private photo directory, creating it when necessary.
*
* @return directory covered by the narrow FileProvider paths rule
* @throws IllegalStateException if the directory cannot be created
*/
private File photoDirectory() {
File directory = new File(getFilesDir(), "photos");
if (!directory.exists() && !directory.mkdirs())
throw new IllegalStateException("Cannot create photos directory");
return directory;
}
/**
* Allocates a new pending destination and launches an external camera with its URI.
* The last completed photo remains selected until this attempt succeeds.
*/
private void captureFullPhoto() {
pendingPhotoName = UUID.randomUUID() + ".jpg";
File photo = new File(photoDirectory(), pendingPhotoName);
Uri uri = FileProvider.getUriForFile(this, getPackageName() + ".files", photo);
takePicture.launch(uri);
}
ה־Bundle שומר את שם הקובץ הממתין אם ה־Activity נוצרת מחדש בזמן שהמצלמה פתוחה; ההעדפה המקומית שומרת את שם הקובץ האחרון שהושלם בין הפעלות. אל תשמרו Bitmap גדול ב־Bundle. שם UUID הוא מזהה קובץ מקומי ולא הרשאה. captureFullPhoto() בקוד המשלים גם מטפלת ב־ActivityNotFoundException אם אין אפליקציית מצלמה. filesDir הוא אחסון ייעודי לאפליקציה; לצורך פרסום בגלריה של המכשיר תידרש זרימת MediaStore נפרדת לפי מדריך המדיה המשותפת.
4. מפענחים תמונה בגודל תצוגה ומתייחסים ל־EXIF
הוסיפו ExecutorService imageWorker = Executors.newSingleThreadExecutor() כשדה. showSavedPhoto(File) בקוד המשלים קוראת תחילה את גבולות התמונה באמצעות BitmapFactory.Options.inJustDecodeBounds, בוחרת inSampleSize עד שהצד הארוך אינו עולה בערך על 800 פיקסלים, מפענחת ברקע, ואז קוראת ExifInterface.TAG_ORIENTATION ומפעילה Matrix להיפוך/סיבוב. Mirror אופקי/אנכי ו־transpose/transverse דורשים היפוך בנוסף לסיבוב; בדקו אותם בנפרד. רק את העדכון של ImageView ו־result היא מעבירה ל־UI thread באמצעות runOnUiThread. ב־onDestroy קוראים imageWorker.shutdown().
זה החלק שבו גודל הקובץ ו־גודל ה־Bitmap בזיכרון נפרדים: JPEG של כמה מאות KB יכול להיפתח למיליוני פיקסלים. טעינת תמונה מלאה על ה־UI thread עלולה להקפיא מסך או לגרום לכשל זיכרון. EXIF עשוי לקבוע שהפיקסלים נשמרו לרוחב אף שהתמונה צריכה להופיע לאורך. השוו את מספר המעלות שמציג המסך לצילום אנכי ואופקי. קראו את ExifInterface ואת BitmapFactory.Options.
בקוד המשלים נמצאות גם deleteSavedPhoto() ו־showSavedPhoto המלאות. עקבו אחריהן: המחיקה מסירה קובץ והעדפה רק לאחר הצלחת File.delete(), ו־callback ישן של פענוח אינו רשאי להחזיר למסך תמונה שנמחקה. imageGeneration משתנה בכל בקשת פענוח, בחירת תמונה אחרת, מחיקה מוצלחת או destruction. בודקים אותו גם בתוצאת הצלחה וגם בכשל אחרי ההעברה ל־main. בדיקת שם הקובץ בהעדפה מוודאת בנוסף שזו עדיין התמונה השמורה. כל קריאת השדה ושינוי שלו מתבצעים על main; שרשור הפענוח משתמש רק ב־expected שנלכד עבורו.
5. מציגים גם בחירה קיימת
ב־callback של pickPhoto הקיים החליפו את הביטוי היחיד בבלוק: אם uri == null, הציגו selection_cancelled; אחרת הציגו את MIME type וקראו binding.image.setImageURI(uri), אחרי הגדלת imageGeneration. זו דרך קצרה לתצוגה זמנית; לטעינת תמונות גדולות מספק חיצוני במוצר נשתמש גם כאן בפענוח ברקע ובגודל יעד, ולא נניח שהקריאה הקצרה אינה מבצעת עבודה על main. זהו preview זמני של הפריט שנבחר; אין כאן העתקה לאחסון פרטי או התחייבות להציג אותו אחרי restart. שימו לב שמספרי content:// ומבנה הנתיבים של ספק המדיה אינם API של קבצים.
הקוד המשלים במלואו
הקטעים הגלויים בשיעור ממקדים את הרעיון; הקבצים הבאים משלימים את כל הקוד הדרוש, עם תיעוד והערות. קראו את השינוי יחד עם ההסבר שמעליו. הם חלק מן השיעור ואינם דורשים פתיחת ענף דוגמה או אתר שפורסם.
MainActivity.java
פתיחת המקור ישירות — בקובץ Markdown המקורי, הקוד נמצא ב־code/19/MainActivity.java.md ביחס לשיעור.
הקוד המלא והשינויים עבור MainActivity.java
ב־MainActivity.java החילו את שינויי האזורים הבאים. שורות + נוספות, שורות - מוסרות, שורות ההקשר נשארות; ⁞ מסמן אזור קיים שלא הוצג. שימרו את שאר קוד התבנית.
import android.app.Notification;
import android.app.NotificationChannel;
import android.app.NotificationManager;
+import android.content.ActivityNotFoundException;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.graphics.Matrix;
+import android.media.ExifInterface;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
⁞
import androidx.annotation.RequiresApi;
import androidx.core.app.ActivityCompat;
import androidx.core.content.ContextCompat;
+import androidx.core.content.FileProvider;
import androidx.core.graphics.Insets;
import androidx.core.view.ViewCompat;
import androidx.core.view.WindowInsetsCompat;
import com.example.topics.databinding.ActivityMainBinding;
+import java.io.File;
+import java.io.IOException;
+import java.util.UUID;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+
public class MainActivity extends AppCompatActivity {
private static final String CHANNEL = "contract_lab";
private ActivityMainBinding binding;
+ private String pendingPhotoName;
+ private int imageGeneration;
+ private final ExecutorService imageWorker = Executors.newSingleThreadExecutor();
private final ActivityResultLauncher<String> requestNotification = registerForActivityResult(
new ActivityResultContracts.RequestPermission(), granted -> {
if (granted) postNotification();
else binding.result.setText(R.string.notification_denied);
});
private final ActivityResultLauncher<PickVisualMediaRequest> pickPhoto =
- registerForActivityResult(new ActivityResultContracts.PickVisualMedia(), uri ->
- binding.result.setText(uri == null ? getString(R.string.selection_cancelled)
- : getString(R.string.selected_type,
- getContentResolver().getType(uri))));
+ registerForActivityResult(new ActivityResultContracts.PickVisualMedia(), uri -> {
+ if (uri == null) {
+ binding.result.setText(R.string.selection_cancelled);
+ } else {
+ binding.result.setText(getString(R.string.selected_type,
+ getContentResolver().getType(uri)));
+ imageGeneration++; // A pending saved-photo decode must not replace this selection.
+ binding.image.setImageURI(uri); // Temporary grant: no filesystem path is needed.
+ }
+ });
private final ActivityResultLauncher<String[]> openDocument = registerForActivityResult(
new ActivityResultContracts.OpenDocument(), uri -> {
if (uri == null) {
⁞
}
try {
// Persist the read grant, not a copy of the document or its URI string.
getContentResolver().takePersistableUriPermission(uri,
Intent.FLAG_GRANT_READ_URI_PERMISSION);
binding.result.setText(getString(R.string.document_selected,
getContentResolver().getType(uri)));
⁞
});
private final ActivityResultLauncher<Void> cameraPreview = registerForActivityResult(
new ActivityResultContracts.TakePicturePreview(), this::showPreview);
+ private final ActivityResultLauncher<Uri> takePicture = registerForActivityResult(
+ new ActivityResultContracts.TakePicture(), saved -> {
+ if (pendingPhotoName == null) return;
+ File photo = new File(photoDirectory(), pendingPhotoName);
+ if (saved && photo.length() > 0) {
+ // The preference names a completed photo only after the camera reports success.
+ String previous = getPreferences(MODE_PRIVATE).getString("photo", null);
+ getPreferences(MODE_PRIVATE).edit().putString("photo", pendingPhotoName).apply();
+ if (previous != null && !previous.equals(pendingPhotoName)) {
+ new File(photoDirectory(), previous).delete();
+ }
+ showSavedPhoto(photo);
+ } else {
+ photo.delete();
+ binding.result.setText(R.string.selection_cancelled);
+ }
+ pendingPhotoName = null;
+ });
/**
* Creates the current Activity View tree and connects the screen's actions.
⁞
EdgeToEdge.enable(this);
binding = ActivityMainBinding.inflate(getLayoutInflater());
setContentView(binding.getRoot());
+ if (savedInstanceState != null) pendingPhotoName = savedInstanceState.getString("pending_photo");
ViewCompat.setOnApplyWindowInsetsListener(binding.main, (v, insets) -> {
Insets bars = insets.getInsets(WindowInsetsCompat.Type.systemBars() | WindowInsetsCompat.Type.displayCutout());
v.setPadding(bars.left, bars.top, bars.right, bars.bottom);
⁞
binding.openDocument.setOnClickListener(view -> openDocument.launch(
new String[]{"application/pdf", "text/plain"}));
binding.cameraPreview.setOnClickListener(view -> cameraPreview.launch(null));
+ binding.takePicture.setOnClickListener(view -> captureFullPhoto());
+ binding.deletePhoto.setOnClickListener(view -> deleteSavedPhoto());
+ String savedPhoto = getPreferences(MODE_PRIVATE).getString("photo", null);
+ if (savedPhoto != null) showSavedPhoto(new File(photoDirectory(), savedPhoto));
+ }
+
+ /**
+ * Preserves the outstanding camera destination across Activity recreation.
+ * The completed photo name lives separately in preferences.
+ *
+ * @param outState small Activity recreation snapshot
+ */
+ @Override
+ protected void onSaveInstanceState(Bundle outState) {
+ outState.putString("pending_photo", pendingPhotoName);
+ super.onSaveInstanceState(outState);
+ }
+
+ /**
+ * Returns this app's private photo directory, creating it when necessary.
+ *
+ * @return directory covered by the narrow FileProvider rule
+ * @throws IllegalStateException if the directory cannot be created
+ */
+ private File photoDirectory() {
+ File directory = new File(getFilesDir(), "photos");
+ if (!directory.exists() && !directory.mkdirs()) throw new IllegalStateException("Cannot create photos directory");
+ return directory;
+ }
+
+ /**
+ * Allocates a unique pending destination and launches an external camera with its URI.
+ * The last completed photo stays selected until the new attempt succeeds.
+ */
+ private void captureFullPhoto() {
+ pendingPhotoName = UUID.randomUUID() + ".jpg";
+ File photo = new File(photoDirectory(), pendingPhotoName);
+ Uri uri = FileProvider.getUriForFile(this, getPackageName() + ".files", photo);
+ try {
+ takePicture.launch(uri);
+ } catch (ActivityNotFoundException noCameraApp) {
+ pendingPhotoName = null;
+ binding.result.setText(R.string.no_camera_app);
+ }
+ }
+
+ /**
+ * Decodes a sampled and EXIF-oriented Bitmap off main.
+ * Only the current image request may update the current View tree.
+ *
+ * @param photo completed app-private photo file
+ */
+ private void showSavedPhoto(File photo) {
+ int expected = ++imageGeneration;
+ if (!photo.isFile() || photo.length() == 0) {
+ binding.result.setText(R.string.photo_missing);
+ return;
+ }
+ imageWorker.execute(() -> {
+ try {
+ BitmapFactory.Options bounds = new BitmapFactory.Options();
+ // Discover dimensions without allocating the full pixel buffer.
+ bounds.inJustDecodeBounds = true;
+ BitmapFactory.decodeFile(photo.getAbsolutePath(), bounds);
+ BitmapFactory.Options sample = new BitmapFactory.Options();
+ sample.inSampleSize = 1;
+ while (Math.max(bounds.outWidth, bounds.outHeight) / sample.inSampleSize > 800) {
+ // Powers of two reduce decoded pixels before creating the display Bitmap.
+ sample.inSampleSize *= 2;
+ }
+ Bitmap bitmap = BitmapFactory.decodeFile(photo.getAbsolutePath(), sample);
+ if (bitmap == null) throw new IOException("Unsupported image");
+ int orientation = new ExifInterface(photo.getAbsolutePath()).getAttributeInt(
+ ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL);
+ Matrix matrix = new Matrix();
+ int degrees = 0;
+ switch (orientation) {
+ case ExifInterface.ORIENTATION_FLIP_HORIZONTAL:
+ matrix.postScale(-1, 1);
+ break;
+ case ExifInterface.ORIENTATION_ROTATE_180:
+ degrees = 180;
+ break;
+ case ExifInterface.ORIENTATION_FLIP_VERTICAL:
+ matrix.postScale(1, -1);
+ break;
+ case ExifInterface.ORIENTATION_TRANSPOSE:
+ matrix.postScale(1, -1);
+ degrees = 90;
+ break;
+ case ExifInterface.ORIENTATION_ROTATE_90:
+ degrees = 90;
+ break;
+ case ExifInterface.ORIENTATION_TRANSVERSE:
+ matrix.postScale(-1, 1);
+ degrees = 90;
+ break;
+ case ExifInterface.ORIENTATION_ROTATE_270:
+ degrees = 270;
+ break;
+ default:
+ break;
+ }
+ matrix.postRotate(degrees);
+ Bitmap oriented = Bitmap.createBitmap(bitmap, 0, 0, bitmap.getWidth(), bitmap.getHeight(), matrix, true);
+ final int exifDegrees = degrees;
+ runOnUiThread(() -> {
+ if (isDestroyed() || expected != imageGeneration || !photo.getName().equals(
+ getPreferences(MODE_PRIVATE).getString("photo", null))) return;
+ binding.image.setImageBitmap(oriented);
+ binding.result.setText(getString(R.string.saved_photo, photo.length(), bounds.outWidth, bounds.outHeight, exifDegrees));
+ });
+ } catch (IOException error) {
+ runOnUiThread(() -> {
+ if (!isDestroyed() && expected == imageGeneration && photo.getName().equals(
+ getPreferences(MODE_PRIVATE).getString("photo", null)))
+ binding.result.setText(R.string.photo_missing);
+ });
+ }
+ });
+ }
+
+ /**
+ * Removes a completed photo and its preference only after file deletion succeeds.
+ */
+ private void deleteSavedPhoto() {
+ String name = getPreferences(MODE_PRIVATE).getString("photo", null);
+ if (name == null) return;
+ if (new File(photoDirectory(), name).delete()) {
+ imageGeneration++; // Deletion invalidates both success and error callbacks.
+ getPreferences(MODE_PRIVATE).edit().remove("photo").apply();
+ binding.image.setImageDrawable(null);
+ binding.result.setText(R.string.photo_deleted);
+ }
+ }
+
+ /**
+ * Invalidates image results and shuts down this Activity's worker.
+ */
+ @Override
+ protected void onDestroy() {
+ imageGeneration++;
+ imageWorker.shutdown();
+ super.onDestroy();
}
/**
activity_main.xml
פתיחת המקור ישירות — בקובץ Markdown המקורי, הקוד נמצא ב־code/19/activity_main.xml.md ביחס לשיעור.
הקוד המלא והשינויים עבור activity_main.xml
ב־activity_main.xml החילו את שינויי האזורים הבאים. שורות + נוספות, שורות - מוסרות, שורות ההקשר נשארות; ⁞ מסמן אזור קיים שלא הוצג. שימרו את שאר קוד התבנית.
android:layout_height="wrap_content"
android:text="@string/camera_preview" />
+ <Button
+ android:id="@+id/take_picture"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:text="@string/take_picture" />
+
+ <Button
+ android:id="@+id/delete_photo"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:text="@string/delete_photo" />
+
+ <ImageView
+ android:id="@+id/image"
+ android:layout_width="match_parent"
+ android:layout_height="220dp"
+ android:contentDescription="@string/photo_preview_description"
+ android:scaleType="fitCenter" />
+
<TextView
android:id="@+id/result"
android:layout_width="match_parent"
strings.xml
פתיחת המקור ישירות — בקובץ Markdown המקורי, הקוד נמצא ב־code/19/strings.xml.md ביחס לשיעור.
הקוד המלא והשינויים עבור strings.xml
ב־strings.xml החילו את שינויי האזורים הבאים. שורות + נוספות, שורות - מוסרות, שורות ההקשר נשארות; ⁞ מסמן אזור קיים שלא הוצג. שימרו את שאר קוד התבנית.
<string name="selected_type">Selected image type: %1$s</string>
<string name="document_selected">Document access kept for type: %1$s</string>
<string name="document_temporary">Document opened, but persistent access was unavailable.</string>
- <string name="preview_size">Camera preview size: %1$d × %2$d (not saved).</string>
+ <string name="preview_size">Camera preview size: %1$d × %2$d (not saved).</string>
+ <string name="take_picture">Take full photo</string>
+ <string name="delete_photo">Delete saved photo</string>
+ <string name="photo_preview_description">Selected or captured photo</string>
+ <string name="saved_photo">Saved photo: %1$d bytes, %2$d × %3$d pixels, EXIF rotation %4$d°.</string>
+ <string name="photo_missing">Saved photo is missing or unreadable.</string>
+ <string name="photo_deleted">Saved photo deleted.</string>
+ <string name="no_camera_app">No camera app can take a photo on this device.</string>
</resources>
file_paths.xml
פתיחת המקור ישירות — בקובץ Markdown המקורי, הקוד נמצא ב־code/19/file_paths.xml.md ביחס לשיעור.
הקוד המלא והשינויים עבור file_paths.xml
צרו file_paths.xml במיקום המתואר בשיעור. זהו קובץ חדש, ולכן הקוד מוצג במלואו.
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<files-path name="photos" path="photos/" />
</paths>
בדיקה על אמולטור
- צלמו דרך Take full photo ואשרו במסך המצלמה. ודאו ש־
resultמציג בתים, רוחב/גובה וסיבוב EXIF ושהתמונה נראית. - סגרו בכוח את האפליקציה ופתחו שוב: אותה תמונה מופיעה. בדיקת המעבדה הראתה קובץ JPEG של 63,540 bytes בגודל 1440×1920 גם אחרי restart.
- לחצו Delete saved photo. ודאו שהתמונה והקובץ נעלמו. צלמו שוב ובטלו: הביטול אינו יוצר תמונה שמורה חדשה.
- בחרו תמונה דרך Photo Picker, בטלו בחירה בניסיון נוסף, והשוו להתנהגות הצילום. בדקו גם סיבוב מסך כשהמצלמה פתוחה, תמונה בעלת EXIF שונה, מכשיר קטן וקורא מסך.
שאלת העברה: אם המוצר דורש שתמונה תופיע בגלריה של יישומים אחרים, איזה רכיב אחסון תחליפו ולמה FileProvider פרטי אינו פתרון לפרסום קבוע? אם המוצר צריך את המצלמה בתוך המסך, אילו הרשאות ומחזור חיים משתנים?