This article is about the final project in my Android development (Getting started) course: the inventory application. This project is hosted on my GitHub, specifically InventoryApp Repository. The project introduction is detailed on README. Welcome star and Fork.

The main purpose of this field project is to practice using SQLite database on Android. Unlike practice Project 9: Habit recording app operating database directly in the Activity, InventoryApp uses a framework more in line with Android design specifications, namely

  • Database side

    (1) UseContractClass defines database-related constants, such as the Content URI and its MIME type, the table name of the database, and the column names.

    (2) Use customSQLiteOpenHelperClass to manage the database, such as creating database tables and upgrading the database schema.

    (3) Use customContentProviderClass implements CRUD operations on the database, including data validation for database updates and inserts.
  • The UI end

    throughContentResolverThe database inserts, updates, deletes the data interaction, and reads the data throughCursorLoaderThread implementation in the background.

InventoryApp’s database framework is the same as that of InventoryApp, so please refer to course 3: Introduction to Content Providers for details. It is worth noting that InventoryApp’s database needs to store images, but instead of storing the image data directly in the database (such as converting the image to byte[] and storing it as BLOB), it stores the URI of the image, which greatly reduces the size of the database. It also reduces the burden of processing data for applications.

In addition, InventoryApp also uses a lot of other interesting Android components, this article is to share as usual, I hope to help you, welcome to exchange. In order to simplify the length, the code in this article has been deleted. Please refer to the code in GitHub.

Key words: RecyclerView & CursorLoader, Glide, Runtime Permissions, DialogFragment, take photos through camera application and select pictures in album, FileProvider, AsyncTask, Intent To Email with Attachment, InputFilter, RegEx, Disable device screen rotation, Drawable Resources, FloatingActionButton

RecyclerView receives data from the CursorLoader to populate the list

Although the ListView and GridView introduced in the course can easily work with the CursorLoader to display lists, RecyclerView is an upgrade of ListView, which is a more flexible Android component. Especially when the subitems of the list need to load a large amount of data or the data of the subitems need to be updated frequently, RecyclerView is more suitable for this application scenario. For example, in practice project 7&8: Data acquisition from Web API, BookListing App implements RecyclerView list of extensible CardView effect, as shown in the figure below.

For a tutorial on RecyclerView, see the Android Developers document. In InventoryApp, first create a RecyclerView object in CatalogActivity and initialize it. Here, setLayoutManager sets the layout mode of the list to a two-column, staggered vertical list. Among them, this StaggeredGridLayout is also a reason for InventoryApp to use RecyclerView; The GridView by default displays only aligned grids, and when the sizes (width or height) between subitems are different, the largest one will be aligned, creating unnecessary gaps.

In CatalogActivity.java

@Override
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_catalog); RecyclerView recyclerView = findViewById(R.id.list); recyclerView.setLayoutManager(new StaggeredGridLayoutManager(2, StaggeredGridLayoutManager.VERTICAL)); mAdapter = new InventoryAdapter(this, null); recyclerView.setAdapter(mAdapter); . }Copy the code

Of course, RecyclerView also uses adapter mode to populate the list, and the business logic is similar to CursorAdapter: First create a new child view using onCreateViewHolder, then populate the view with data using onBindViewHolder; When a view is recycled, data is populated directly into the reclaimed view via onBindViewHolder. The difference is that the RecyclerView list sub-item layout needs to be provided by the custom recyclerView. ViewHolder class, the specific application process is

  1. First of all inonCreateViewHolderCreates a custom ViewHolder object based on the subitem layout.
  2. The custom ViewHolder object is then passed toonBindViewHolderData populates the subitems at the corresponding positions.

Therefore, the RecyclerView Adapter in InventoryApp is customized as InventoryAdapter. Note that the extends parameter after the class name is RecyclerView.Adapter, whose generic parameter is VH, The custom recyclerView.viewholder is implemented here as the inner class of the adapter.

In InventoryAdapter.java

public class InventoryAdapter extends RecyclerView.Adapter<InventoryAdapter.MyViewHolder> {

    private Cursor mCursor;
    private Context mContext;

    public InventoryAdapter(Context context, Cursor cursor) {
        mContext = context;
        mCursor = cursor;
    }

    @Override
    public int getItemCount() {
        if (mCursor == null) {
            return 0;
        } else {
            returnmCursor.getCount(); } } public class MyViewHolder extends RecyclerView.ViewHolder { private ImageView imageView; private TextView nameTextView, priceTextView, quantityTextView; private FloatingActionButton fab; private MyViewHolder(View view) { super(view); imageView = view.findViewById(R.id.item_image); nameTextView = view.findViewById(R.id.item_name); priceTextView = view.findViewById(R.id.item_price); quantityTextView = view.findViewById(R.id.item_quantity); fab = view.findViewById(R.id.fab_sell); }}... }Copy the code
  1. First, we define the constructor of InventoryAdapter. The input parameters are Context and Cursor objects, where Cursor contains the contents of the list to be displayed. It is defined as a global variable to enable it to be displayed bygetItemCountAnd so on. When initializing or resetting an adapter, Cursor can pass null to indicate that no data is displayed in the list and that the adapter is error free.
  2. Then implement the custom RecyclerView.ViewHolder class, named MyViewHolder, whose constructor finds the View to fill with data from the incoming View object (usually generated from Layout). Note that these views need to be declared as global variables of the inner class MyViewHolder; Also don’t forget to call the super class inside the constructor, taking the View object as input.

With that in mind, InventoryAdapter can populate the list of data based on custom ViewHolder objects. We create a MyViewHolder by using LayoutInflater in onCreateViewHolder to generate a View object from the layout file of the list’s subitems. Finally, the MyViewHolder object is returned.

In InventoryAdapter.java

@NonNull
@Override
public InventoryAdapter.MyViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
    View itemView = LayoutInflater.from(parent.getContext()).inflate(R.layout.list_item, parent, false);
    MyViewHolder myViewHolder = new MyViewHolder(itemView);

    return myViewHolder;
}
Copy the code

The onBindViewHolder is then populated with data based on the MyViewHolder object passed in as well as the Cursor. Note that before you can do anything, you need to move the Cursor to its current position.

In InventoryAdapter.java

@Override
public void onBindViewHolder(@NonNull final InventoryAdapter.MyViewHolder holder, int position) {
    if(mCursor.moveToPosition(position)) { ... GlideApp.with(mContext).load(imageUriString) .transforms(new CenterCrop(), new RoundedCorners( (int) mContext.getResources().getDimension(R.dimen.background_corner_radius))) .into(holder.imageView); . }}Copy the code

So far, the basic framework of RecyclerView adapter has been realized. But there are a few caveats to real-world applications within InventoryApp.

A, Glide

For Android, displaying multiple images in a list is a time-consuming and performance-consuming task. Whether and how to put tasks such as reading images from resources and clipping images according to view size into background threads is a major sinkhole in InventoryApp development. After consulting this Android Developers document, I learned that Glide Library can perfectly capture, decode and display pictures with only one line of code in most cases, and it even supports GIFs and video snapshots.

In InventoryApp, the Generated API of Glide’s current latest V4 version (stable, v3 version no longer maintained) is used, The main reason is the need to use Glide’s multiple transformations to set the centerCrop pattern and round corners of the image. Glide is very well documented and very easy to get started, so I won’t repeat it here.

Second, the swapCursor

Since RecyclerView needs to receive data from CursorLoader in InventoryApp, onLoadFinished and onLoaderReset need to call the adapter’s swapCursor method, And RecyclerView does not provide the corresponding method similar to ListView, so it needs to be implemented in the adapter.

In InventoryAdapter.java

public void swapCursor(Cursor cursor) {
    mCursor = cursor;
    notifyDataSetChanged();
}
Copy the code

In this case, the swapCursor method takes a Cursor object as an input parameter; Within the method, update the Cursor global variable within the adapter, notifies the adapter list that the data set has changed.

Click event listeners for list subitems

The View object generated in onCreateViewHolder represents each list subitem, and setting OnClickListener on it responds to the click event of the list subitem.

In InventoryAdapter.java

@NonNull
@Override
public InventoryAdapter.MyViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
    View itemView = LayoutInflater.from(parent.getContext()).inflate(R.layout.list_item, parent, false);
    final MyViewHolder myViewHolder = new MyViewHolder(itemView);
    // Setup each item listener here.
    itemView.setOnClickListener(new View.OnClickListener() {
        @Override
        public void onClick(View view) {
            int position = myViewHolder.getAdapterPosition();
            if(mOnItemClickListener ! = null) { // Send the click event back to the host activity. mOnItemClickListener.onItemClick(view, position, getItemId(position)); }}});return myViewHolder;
}

public long getItemId(int position) {
    if(mCursor ! = null) {if (mCursor.moveToPosition(position)) {
            int idColumnIndex = mCursor.getColumnIndex(InventoryEntry._ID);
            returnmCursor.getLong(idColumnIndex); }}return 0;
}
Copy the code
  1. The first call to MyViewHoldergetAdapterPosition()Method to get the position of the current child.
  2. Then call OnItemClickListeneronItemClickMethod to respond to the click event of the CatalogActivity subitem in RecyclerView. The input parameters include the location of the current subitem and its ID in the database, where ID passesgetItemIdMethod to query Cursor to obtain the corresponding key.

In InventoryApp, the clicked action of each child of RecyclerView is to jump from CatalogActivity to DetailActivity, where the Intent component is used. So it makes sense to respond to click events for CatalogActivity subitems. But recyclerView. Adapter doesn’t have a default click-event listener, so you need to implement it yourself.

In InventoryAdapter.java

private OnItemClickListener mOnItemClickListener;

public void setOnItemClickListener(OnItemClickListener onItemClickListener) {
    mOnItemClickListener = onItemClickListener;
}

public interface OnItemClickListener {
    void onItemClick(View view, int position, long id);
}
Copy the code
  1. First define an interface called OnItemClickListener and place one in itonItemClickMethod that must be implemented when an Activity or Fragment instantiates the interface.
  2. The OnItemClickListener interface is then defined as a global variable that can be applied by other methods within the adapter.
  3. And finally define asetOnItemClickListenerMethod, taking the instantiation object of the OnItemClickListener interface as an input parameter, and assigning the OnItemClickListener object passed in to the global variable described above, Here you pass an instantiation object of the OnItemClickListener interface implemented by your Activity or Fragment into the adapter.

This code structure is typical of Java inheritance. RecyclerView RecyclerView adapter calls setOnItemClickListener; Pass in a new OnItemClickListener object and implement the onItemClick method in it. The same code structure with ListView AdapterView. OnItemClickListener.

In CatalogActivity.java

@Override
protected void onCreate(Bundle savedInstanceState) {

    ...

    mAdapter.setOnItemClickListener(new InventoryAdapter.OnItemClickListener() { @Override public void onItemClick(View view, int position, long id) { Intent intent = new Intent(CatalogActivity.this, DetailActivity.class); Uri currentItemUri = ContentUris.withAppendedId(InventoryEntry.CONTENT_URI, id); intent.setData(currentItemUri); startActivity(intent); }}); }Copy the code

Fourth, the Empty View

Adding an empty view to the RecyclerView list is necessary to improve the user experience. Since RecyclerView receives data from the CursorLoader, Therefore, you can use CursorLoader to determine the state of the list in the onLoadFinished method after the data is loaded. If the list is empty, the empty view is displayed. If there is data in the list, the empty view is eliminated.

In CatalogActivity.java

@Override
public void onLoadFinished(Loader<Cursor> loader, Cursor data) {
    mAdapter.swapCursor(data);

    View emptyView = findViewById(R.id.empty_view);
    if (mAdapter.getItemCount() == 0) {
        emptyView.setVisibility(View.VISIBLE);
    } else{ emptyView.setVisibility(View.GONE); }}Copy the code

Runtime permission requests

Including picture files in InventoryApp involves Android’s dangerous permissions, so the app needs to request the STORAGE permission group to read or write files in external STORAGE. For more information on Android permissions, see Course 2: HTTP Networking.

So, first add parameters to AndroidManifest, below the top-level elements. Here, only one WRITE_EXTERNAL_STORAGE parameter is added, but no READ_EXTERNAL_STORAGE parameter is added. This is because they belong to the same permission group. When an application obtains the write permission of the former, it automatically obtains the read permission of the latter.

In AndroidManifest.xml

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.example.android.inventoryapp">

    <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />

    <application ...>
        ...
    </application>
</manifest>
Copy the code

Note: Starting with Android 4.4 KitKat (API Level 19), when an application reads or writes files in its own directory (visible only to the application) using getExternalFilesDir(String) and getExternalCacheDir(), STORAGE permission groups do not need to be requested.

At this point, for devices running Android 5.1 (API Level 22) or below, InventoryApp will pop up a dialog at Install Time showing the STORAGE permission group the app has requested, and users must agree to the request. Otherwise, the application cannot be installed. For devices running on Android 6.0 (API level 23) or above, you need to pop-up a dialog box to request STORAGE permission group when InventoryApp runs (Runtime); If the application does not have code to handle runtime permission requests, it does not have the permission by default.

Therefore, the application needs to request permission from the user at the appropriate time. Since STORAGE permission groups required by InventoryApp are only involved for image-related operations, OnClickListener is set in the DetailActivity’s unique entry for processing images to handle run-time permission requests.

In DetailActivity.java

@Override
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_detail); . View imageContainer = findViewById(R.id.item_image_container); imageContainer.setOnClickListener(new View.OnClickListener() {
        @Override
        public void onClick(View v) {
            // Check permission before anything happens.
            if (hasPermissionExternalStorage()) {
                // Permission has already been granted, thenstart the dialog fragment. startImageChooserDialogFragment(); }}}); }Copy the code

When the picture edit box is clicked, a helper method is called in the listener to determine whether the required permissions have been obtained and return true to proceed. It’s worth noting that InventoryApp must check that it has the required permissions every time an image edit box is clicked, as starting with Android 6.0 Marshmallow (API Level 23), users can revoke permissions given to the app at any time.

In DetailActivity.java

private boolean hasPermissionExternalStorage() {
    if(ContextCompat.checkSelfPermission(getApplicationContext(), Manifest.permission.WRITE_EXTERNAL_STORAGE) ! = PackageManager.PERMISSION_GRANTED) { // Permission is NOT granted.if (ActivityCompat.shouldShowRequestPermissionRationale(DetailActivity.this,
                Manifest.permission.WRITE_EXTERNAL_STORAGE)) {
            // Show an explanation with snack bar to user if needed.
            Snackbar snackbar = Snackbar.make(findViewById(R.id.editor_container),
                    R.string.permission_required, Snackbar.LENGTH_LONG);
            // Prompt user a OK button to request permission.
            snackbar.setAction(android.R.string.ok, new View.OnClickListener() { @Override public void onClick(View v) { // Request the permission. ActivityCompat.requestPermissions(DetailActivity.this, new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE}, PERMISSION_REQUEST_EXTERNAL_STORAGE); }}); snackbar.show(); }else {
            // Request the permission directly, if it doesn't need to explain. ActivityCompat.requestPermissions(DetailActivity.this, new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE}, PERMISSION_REQUEST_EXTERNAL_STORAGE); } return false; } else { // Permission has already been granted, then return true. return true; }}Copy the code
  1. In hasPermissionExternalStorage auxiliary method, first of all determine whether the application has gained WRITE_EXTERNAL_STORAGE permission, if it returns true.
  2. If your application doesn’t already have the permissions it needs, first go through ActivityCompat’sshouldShowRequestPermissionRationaleMethod to determine whether the user needs to be shown the reason for requesting the permission. If not, go directly to ActivityCompatrequestPermissionsMethod to request permission, where the input parameters are

    (1) Activity: the current activity that requests permission, in this case, DetailActivity.

    Permissions: The list of permissions that need to be requested, passed in as a string list object, cannot be empty.

    (3) requestCode: The unique identifier of the permission request, usually defined as a global integer constant, which is used when receiving the result of the permission request.
  3. If the user has previously rejected permission requests, thenshouldShowRequestPermissionRationaleMethod returns true, indicating that the user needs to be shown the reason for requesting the permission and the permission request is processed asynchronously. Here, a Snackbar pops up showing the reason for requesting this permission and provides an OK button that the user clicks through ActivityCompatrequestPermissionsMethod, and the application pops up a standard (The application cannot be configured or changed) dialog box for the user to choose whether to agree to the permission request.

Application after launching an authorization request, the user the choice of obtained through onRequestPermissionsResult, in response to the request of the different results here.

In DetailActivity.java

@Override
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions,
                                       @NonNull int[] grantResults) {
    if (requestCode == PERMISSION_REQUEST_EXTERNAL_STORAGE) {
        if (grantResults.length == 1 && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
            // For the first time, permission was granted, then start the dialog fragment.
            startImageChooserDialogFragment();
        } else{ // Prompt to user that permission request was denied. Toast.makeText(this, R.string.toast_permission_denied, Toast.LENGTH_SHORT) .show(); }}else{ super.onRequestPermissionsResult(requestCode, permissions, grantResults); }}Copy the code
  1. The request is first distinguished by the unique identifier of the permission request, and if it is not the expected request, the super class is called to keep the default behavior.
  2. For a specific permission request, further judge whether the user agrees with the request, if the following work; If the user refuses, a relevant Toast message is displayed.

At this point, the runtime permission request is almost complete, as shown in the figure below. See this Android Developers document for more information.

Note: InventoryApp also uses the camera app to take photos, but there is no need to request access to the camera because InventoryApp doesn’t directly control the camera hardware module, but instead uses the camera app to retrieve images through the Intent, another advantage of using the Intent.

DialogFragment

In InventoryApp, after the app has been granted access to read and write external storage files, when the user clicks the image edit box in DetailActivity, an auxiliary method is called, bringing up a custom dialog labeled imageChooser that offers two options.

In DetailActivity.java

private void startImageChooserDialogFragment() {
    DialogFragment fragment = new ImageChooserDialogFragment();
    fragment.show(getFragmentManager(), "imageChooser");
}
Copy the code

The custom dialog for ImageChooserDialogFragment, in separate Java file, belong to DialogFragment subclass. Start by creating and returning a Dialog object in the onCreateDialog method.

In ImageChooserDialogFragment.java

public class ImageChooserDialogFragment extends DialogFragment {

    @Override
    public Dialog onCreateDialog(Bundle savedInstanceState) {
        LayoutInflater inflater = getActivity().getLayoutInflater();
        View view = inflater.inflate(R.layout.dialog_image_chooser, null);

        AlertDialog.Builder builder = new AlertDialog.Builder(getActivity());
        builder.setView(view);

        returnbuilder.create(); }... }Copy the code
  1. We start by using LayoutInflater to generate a View object from the dialog’s layout file.
  2. The Dialog box is then configured through AlertDialog.Builder, mainly by setting the View object generated above as the layout of the dialog box.
  3. The last call to the AlertDialog.Builder objectcreate()Method that returns a Dialog object.

Due to the two options ImageChooserDialogFragment click event you need to use Intent component, so with the RecyclerView. Adapter list item click event listeners are the same, There should also be in the call ImageChooserDialogFragment DetailActivity in response to one of the two options click event. Similarly, the interface defined in ImageChooserDialogFragment click event, and related variables and methods.

In ImageChooserDialogFragment.java

private ImageChooserDialogListener mListener;

@Override
public void onAttach(Activity activity) {
    super.onAttach(activity);
    try {
        mListener = (ImageChooserDialogListener) activity;
    } catch (ClassCastException e) {
        throw new ClassCastException(activity.toString()
                + " must implement ImageChooserDialogListener.");
    }
}

public interface ImageChooserDialogListener {
    void onDialogCameraClick(DialogFragment dialog);
    void onDialogGalleryClick(DialogFragment dialog);
}
Copy the code
  1. First define an interface (interface), called ImageChooserDialogListener, placed in two methods, respectively, as two options for the click event response method. When the Activity is using ImageChooserDialogFragment must implement the interface of two methods.
  2. Then ImageChooserDialogListener interface is defined as a global variable, can make it inonAttachMethod is initialized according to the Activity and applied elsewhere, such as inonCreateDialogSet in the two options click event listener, calling ImageChooserDialogListener two methods respectively, said the click event in DetailActivity response.

In ImageChooserDialogFragment.java

@Override
public Dialog onCreateDialog(Bundle savedInstanceState) {
    LayoutInflater inflater = getActivity().getLayoutInflater();
    View view = inflater.inflate(R.layout.dialog_image_chooser, null);

    View cameraView = view.findViewById(R.id.action_camera);
    View galleryView = view.findViewById(R.id.action_gallery);

    cameraView.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { // Send the camera click event back to the host activity. mListener.onDialogCameraClick(ImageChooserDialogFragment.this); // Dismiss the dialog fragment. dismiss(); }}); galleryView.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { // Send the gallery click event back to the host activity. mListener.onDialogGalleryClick(ImageChooserDialogFragment.this); // Dismiss the dialog fragment. dismiss(); }}); . }Copy the code
  1. Start by finding a View with two options, “Camera” and “Album,” based on the View object generated by the layout file.
  2. Camera view click event listener call ImageChooserDialogListeneronDialogCameraClickMethod to respond to the click event in DetailActivity, and then passdismiss()Method to close the dialog box.
  3. Similarly, the album view click event listener call ImageChooserDialogListeneronDialogGalleryClickMethod to respond to the click event in DetailActivity, and then passdismiss()Method to close the dialog box.

More information about Dialog can be found in this Android Developers document.

Take photos through the camera app and select photos in an album

In the call ImageChooserDialogFragment DetailActivity in response to one of the two options click event, namely realize ImageChooserDialogListener interface in the two methods, Here you can take photos through the camera app and select photos from the album.

In DetailActivity.java

public class DetailActivity extends AppCompatActivity implements ImageChooserDialogFragment.ImageChooserDialogListener {  public static final String FILE_PROVIDER_AUTHORITY ="com.example.android.fileprovider.camera";

    private static final int REQUEST_IMAGE_CAPTURE = 0;
    private static final int REQUEST_IMAGE_SELECT = 1;

    @Override
    public void onDialogGalleryClick(DialogFragment dialog) {
        Intent selectPictureIntent = new Intent();
        selectPictureIntent.setAction(Intent.ACTION_GET_CONTENT);
        selectPictureIntent.setType("image/*");
        if(selectPictureIntent.resolveActivity(getPackageManager()) ! = null) { startActivityForResult(selectPictureIntent, REQUEST_IMAGE_SELECT); } } @Override public void onDialogCameraClick(DialogFragment dialog) { Intent takePictureIntent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE);if(takePictureIntent.resolveActivity(getPackageManager()) ! = null) { File imageFile = null; try { imageFile = createCameraImageFile(); } catch (IOException e) { Log.e(LOG_TAG,"Error creating the File " + e);
            }

            if(imageFile ! = null) { Uri imageURI = FileProvider.getUriForFile(this, FILE_PROVIDER_AUTHORITY, imageFile); takePictureIntent.putExtra(MediaStore.EXTRA_OUTPUT, imageURI); startActivityForResult(takePictureIntent, REQUEST_IMAGE_CAPTURE); }}}}Copy the code
  1. Intent.action_get_content is the URI and image/* is the MIME typestartActivityForResultMethod starts an Intent with postback data, with the input parameter being

    (1) Intent: the intent object configured above, in this case, selectPictureIntent.

    (2) requestCode: The unique identifier of an Intent, usually defined as a global integer constant that is used to receive incoming data from an Intent.
  2. Intents that capture photos with a camera app are more sophisticated, creating a file that stores photos taken by the camera app. The complete steps are as follows, for more informationThis Android Developers document.

    ACTION_IMAGE_CAPTURE = mediastore.action_image_capture = mediastore.action_image_capture

    (2) Then create a File object through the helper method, where you need to catch ioExceptions that may be generated by creating the File.

    (3) If the File object is created successfully, then the File object is created through the FileProvidergetUriForFileThe EXTRA_OUTPUT () method gets the URI of the file and passes it to the Intent as EXTRA_OUTPUT data, which specifies where the photos taken by the camera application should be stored.

    (4) Final passstartActivityForResultThe REQUEST_IMAGE_CAPTURE () method starts an Intent with returned data, with a unique identifier of REQUEST_IMAGE_CAPTURE.
  3. In the Intent for shooting photos with the camera app, a helper method is called to create a File object.

    (1) First obtain a fixed format timestamp through SimpleDateFormat, and add the prefix and suffix to form a collision-resistant file name.

    (2) Then pass the EnvironmentgetExternalStoragePublicDirectoryMethod, and the environment. DIRECTORY_PICTURES input parameter, to get a public directory of images. This makes photos taken by the user through the camera app accessible to all apps, which conforms to the Android design specification.

    (3) The last pass FilecreateTempFileMethod creates and returns a File object with input parameters including the File name defined above and the storage directory.

    (4) In addition through the File objectgetAbsolutePath()Method gets the directory path of the newly created image file, which is used when receiving postback data from an Intent.

In DetailActivity.java

    private String mCurrentPhotoPath;

    private File createCameraImageFile() throws IOException {
        String timeStamp = new SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault())
                .format(new Date());
        String imageFileName = "JPEG_" + timeStamp + "_";

        File storageDirectory = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES);
        File imageFile = File.createTempFile(
                imageFileName,      /* prefix    */
                ".jpg",             /* suffix    */
                storageDirectory    /* directory */
        );

        mCurrentPhotoPath = imageFile.getAbsolutePath();

        return imageFile;
    }
Copy the code
  1. In an Intent that takes a photo with the camera app, it takes a photo with the FileProvidergetUriForFileMethod gets the URI of the image file, where the input parameter is

    (1) Context: the current application environment. Here, this represents the current DetailActivity.

    (2) Authority: specifies the host name of the FileProvider, which must be consistent with that specified in AndroidManifest.

    (3) file: the file object whose URI needs to be obtained, here is the imageFile imageFile generated above.

Obviously, the FileProvider provided by Android is used here and needs to be declared in the AndroidManifest.

In AndroidManifest.xml

<application>

   ...

   <provider
        android:name="android.support.v4.content.FileProvider"
        android:authorities="com.example.android.fileprovider.camera"
        android:exported="false"
        android:grantUriPermissions="true">
        <meta-data
            android:name="android.support.FILE_PROVIDER_PATHS"
            android:resource="@xml/file_paths" />
    </provider>
</application>
Copy the code

The metadata specifies the directory of the file, defined in the XML/File_paths directory.

In res/xml/file_paths.xml

<paths> <! -- Declare the path to the public Pictures directory. --> <external-path name="item_images" path="." />
</paths>
Copy the code

Because image files are in a public directory, FileProvider specifies a different file directory than the one inside the app. See this Stack Overflow post for details.

Both intents that take a photo with the camera app and that select an image from an album carry postback data, so override onActivityResult gets the postback data of the Intent.

In DetailActivity.java

private Uri mLatestItemImageUri = null;

@Override
protected void onActivityResult(int requestCode, int resultCode, Intent intent) {
    if (resultCode == RESULT_OK) {
        switch (requestCode) {
            case REQUEST_IMAGE_CAPTURE:
                mLatestItemImageUri = Uri.fromFile(new File(mCurrentPhotoPath));

                GlideApp.with(this).load(mLatestItemImageUri)
                        .transforms(new CenterCrop(), new RoundedCorners(
                                (int) getResources().getDimension(R.dimen.background_corner_radius)))
                        .into(mImageView);
                break;
            case REQUEST_IMAGE_SELECT:
                Uri contentUri = intent.getData();

                GlideApp.with(this).load(contentUri)
                        .transforms(new CenterCrop(), new RoundedCorners(
                                (int) getResources().getDimension(R.dimen.background_corner_radius)))
                        .into(mImageView);

                new copyImageFileTask().execute(contentUri);
                break; }}}Copy the code
  1. First, determine whether the Intent request is successful. If so, handle it according to the unique identifier of different intents.
  2. For an Intent that takes a photo with the camera app, the database stores only the URI of the image, not the image data itself, so the global variable mLatestItemImageUri is assigned to a file URI based on the directory path obtained when creating a new image file. Finally, Glide is used to display pictures.
  3. For an Intent that selects an image in an album, passesgetData()Method to get the Content URI of the image file selected by the user, and then use Glide to display the image. Note that instead of assigning the Content URI obtained from the Intent directly to mLatestItemImageUri, a background AsyncTask thread copies the image file selected by the user to a file in the app’s internal directory. Assign the file URI of the copied file to mLatestItemImageUri.

In DetailActivity.java

private class copyImageFileTask extends AsyncTask<Uri, Void, Uri> {
    @Override
    protected Uri doInBackground(Uri... uris) {
        if (uris[0] == null) {
            return null;
        }

        try {
            File file = createCopyImageFile();

            InputStream input = getContentResolver().openInputStream(uris[0]);
            OutputStream output = new FileOutputStream(file);
            byte[] buffer = new byte[4 * 1024];
            int bytesRead;
            while ((bytesRead = input.read(buffer)) > 0) {
                output.write(buffer, 0, bytesRead);
            }

            input.close();
            output.close();

            return Uri.fromFile(file);
        } catch (IOException e) {
            Log.e(LOG_TAG, "Error creating the File " + e);
        }

        return null;
    }

    @Override
    protected void onPostExecute(Uri uri) {
        if(uri ! = null) { mLatestItemImageUri = uri; }}}Copy the code
  1. The Content URI retrieved from the Intent is passed into the custom AsyncTask class copyImageFileTaskdoInBackgroundMethod to copy files in a background thread.
  2. First check whether the URI is null, and return NULL in advance if it is.
  3. Then call the helper method to create a new File object to store the copied image File. Similar to the logic of the auxiliary method used by the camera application to take photos, this method generates a conflict-resistant File name, obtains a storage directory, and finally creates and returns a File object through the createTempFile method of File.

    The difference is that because here is the scene of selecting images from the album, if the image is copied to the public directory will cause trouble to the user, so this is passedgetExternalFilesDirMethod and the environment. DIRECTORY_PICTURES input parameter to get the internal directory of the application, making the copied image file invisible to other applications. Also, there is no need to get the directory path of the copied files, so no FileProvider is used.

In DetailActivity.java

private File createCopyImageFile() throws IOException {
    String timeStamp = new SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault())
            .format(new Date());
    String imageFileName = "JPEG_" + timeStamp + "_";

    File storageDirectory = getExternalFilesDir(Environment.DIRECTORY_PICTURES);

    return File.createTempFile(
            imageFileName,      /* prefix    */
            ".jpg",             /* suffix    */
            storageDirectory    /* directory */
    );
}
Copy the code
  1. Then read the data from the above Content URI and store it to an InputStream object, and create a new OutputStream based on the above File object. The data of InputStream is then written to OutputStream via byte[] cache, and the two objects are closed after replication to prevent memory leaks.
  2. The last call to the UrifromFileMethod to return a File URI based on the File object that was copied. Then, inonPostExecuteMethod, if thedoInBackgroundIf the URI passed by the method is not null, the URI is assigned to mLatestItemImageUri.

Application at this point, through the camera take photographs as well as in the photo album of pictures of the function is achieved, but there’s a very obvious optimization, that is every time the user through the camera application photos or select images in the album, the application will create a new image file, if the user use camera take photographs, application or continuous selection of pictures in the album, This results in multiple image files, but the application only uses the last image, and even if the user gives up editing at this point, the resulting files are invalidated, increasing the memory footprint of the device and application.

Therefore, the application needs to be able to delete useless files, which can be handled in three different ways.

1. Cancel taking photos in the middle of the camera application

For application through my camera take photographs of the operation, as long as the user clicks the ImageChooserDialogFragment camera options, whether or not the Intent to request is successful, application will create a new file, For example, when the user clicks the camera option in the dialog box and jumps to the camera app, but fails to take a photo and returns to InventoryApp, the new image file created by the action needs to be deleted.

In DetailActivity.java

@Override
protected void onActivityResult(int requestCode, int resultCode, Intent intent) {
    if (resultCode == RESULT_OK) {
        switch (requestCode) {
            case REQUEST_IMAGE_CAPTURE:
                ...

                mCurrentPhotoPath = null;
                break;
            caseREQUEST_IMAGE_SELECT: ... }}else if(mCurrentPhotoPath ! = null) { File file = new File(mCurrentPhotoPath);if(file.delete()) { Toast.makeText(this, android.R.string.cancel, Toast.LENGTH_SHORT).show(); }}}Copy the code

It should be noted that onActivityResult will also be triggered by the operation of selecting an image in the album. For example, the user first takes a photo through the camera app, and then clicks the album option in the dialog box to jump to the album, but returns to InventoryApp without selecting the image. Since the delete action is triggered based on whether mCurrentPhotoPath is null, if mCurrentPhotoPath is not cleared after processing the data returned from the last photo taken by the camera app, the photos taken by the user using the camera app will be deleted by mistake. Therefore, set mCurrentPhotoPath to NULL after processing the returned data in the case entry of the photo taken with the camera app.

Repeat take photos through the camera app or repeatedly select pictures in the album

The user takes photos consecutively with the camera app, or selects images consecutively in an album, resulting in multiple image files, but only the last image is used in the app. The strategy is to delete the old image before replacing it with a new one.

In DetailActivity.java

@Override
protected void onActivityResult(int requestCode, int resultCode, Intent intent) {
    if(resultCode == RESULT_OK) { deleteFile(); . } } private voiddeleteFile() {
    if(mLatestItemImageUri ! = null) { File file = new File(mLatestItemImageUri.getPath());if (file.delete()) {
            Log.v(LOG_TAG, "Previous file deleted."); }}}Copy the code
  1. Because the URIs of photos taken by the user through the camera app or images selected from the album are stored in the global variable mLatestItemImageUri, and the value of mLatestItemImageUri only changes when the user adds an image, So mLatestItemImageUri can be used as an indication of whether the user has previously added images.
  2. inonActivityResultAfter the Intent request succeeds, an auxiliary method is called to delete the old image. In auxiliary methoddeleteFileFirst, determine whether mLatestItemImageUri is null. If it is not null, it indicates that there are old images at this time. Then, a file object is created based on the directory path of the file URI to delete the file. After the operation succeeds, a verbose message is logged.

Third, the user gives up editing

After the user takes a photo through the camera application or selects a picture from the album, he clicks BACK or UP button to give UP editing without saving it, which will cause the new picture file to be useless. Therefore, the countermeasure is to call the auxiliary method deleteFile in the click event listener of BACK or UP button to delete the old picture.

Intent to Email with Attachment

In DetailActivity edit mode, the menu bar has an order button that can be Intent to the mailbox application with information about the current item, including attaching a picture file to the email.

In DetailActivity.java

Intent intent = new Intent(Intent.ACTION_SENDTO);
intent.setData(Uri.parse("mailto:"));

String subject = "Order " + mCurrentItemName;

intent.putExtra(Intent.EXTRA_SUBJECT, subject);

StringBuilder text = new StringBuilder(getString(R.string.intent_email_text, mCurrentItemName));
text.append(System.getProperty("line.separator"));
intent.putExtra(Intent.EXTRA_STREAM, Uri.parse(mCurrentItemImage));

intent.putExtra(Intent.EXTRA_TEXT, text.toString());

if(intent.resolveActivity(getPackageManager()) ! = null) { startActivity(intent); }Copy the code
  1. The first two lines of code ensure that only the mailbox application can respond to the Intent.
  2. Add EXTRA_STREAM data to the Intent as an attachment to the email, passing in the image file’s file URI. Note that if the Content URI is passed in, the mailbox application may not be able to get the specified file due to permissions and other issues.
  3. In the StringBuilderappendAdd the System.getProperty(“line.separator”) resource to wrap the string, which works on all platforms.
  4. Refer to this Android Developers document for adding additional data to intEnts.

InputFilter

Similar to Project 9: Custom Logging, the input limits for the Price EditText in InventoryApp are implemented by a custom InputFilter class.

private class DigitsInputFilter implements InputFilter {

    private Pattern mPattern;

    private DigitsInputFilter(int digitsBeforeDecimalPoint, int digitsAfterDecimalPoint) {
        mPattern = Pattern.compile(getString(R.string.price_pattern,
                digitsBeforeDecimalPoint - 1, digitsAfterDecimalPoint));
    }

    @Override
    public CharSequence filter(CharSequence source, int start, int end,
                               Spanned dest, int dstart, int dend) {

        String inputString = dest.toString().substring(0, dstart)
                + source.toString().substring(start, end)
                + dest.toString().substring(dend, dest.toString().length());

        Matcher matcher = mPattern.matcher(inputString);

        if(! matcher.matches()) {return "";
        }
        returnnull; }}Copy the code
  1. As the custom InputFilter class DigitsInputFilter is only used in DetailActivity, it is implemented as an internal class. There is a key global variable mPattern in DigitsInputFilter class. Used to determine whether user input meets requirements.
  2. The DigitsInputFilter constructor passes in two input limit parameters, the number of digits before and after the decimal point. They are used as part of the input Pattern to determine the input limits of the EditText. In InventoryApp, DigitsInputFilter is specifically used for price EditText, which is called with two parameters, 10 and 2, indicating that up to ten digits can be entered before the decimal point and up to two digits after the decimal point. Here, Pattern is compiled using a regular expression (RegEx), the price regular expression used in InventoryApp is^ (0 | [1-9] [0-9] {0, 9} +) ((\ \. \ \ d {0, 2})?), the input formats it allows can be divided into the following cases

    (1) Starts with a 0 and then only accepts a decimal point (.) Input, do not allow more 0 or 1 ~ 9 digits input; A maximum of two digits from 0 to 9 can be entered after the decimal point.

    (2) Start with 1 to 9, then enter a decimal point (.). Or up to nine digits from 0 to 9; A maximum of two digits from 0 to 9 can be entered after the decimal point.

    (3) Decimal points are not allowed (.) At the beginning.
  3. Override filterMethod defines the code that implements input restrictions that are triggered each time the user enters a character. In this case, all existing characters in the EditText are first retrieved, and then the global variable Pattern is calledmatcherMethod to get a Matcher object, and finally pass thematches()Method to check whether the current input matches Pattern. If so, returnnullIndicates that input is allowed or returned if not""To filter input, replace it with a null character.

Disable device screen rotation

In InventoryApp, the DetailActivity in InventoryApp is destroyed in the background when the user holds the device vertically but takes a photo by holding it horizontally in the camera app while adding a picture to the goods. The app crashed when the user came back after taking the photo. Therefore, InventoryApp’s DetailActivity needs to disable device screen rotation by setting relevant parameters in AndroidManifest.

In AndroidManifest.xml

<activity
    android:name=".DetailActivity"
    android:screenOrientation="sensorPortrait"
    android:configChanges="keyboardHidden|orientation|screenSize"
    android:parentActivityName=".CatalogActivity"
    android:theme="@style/AppTheme"
    android:windowSoftInputMode="stateHidden"> <! -- Parent activity meta-data to support 4.0 and lower --> <meta-data android:name="android.support.PARENT_ACTIVITY"
        android:value=".CatalogActivity" />
</activity>
Copy the code
  1. The android: screenOrientation set as sensorPortrait, make the screen direction throughout the vertical direction sensor (forward or reverse), which in the case of the user to disable the sensor is still valid.
  2. Add the orientation and screenSize parameters to Android :configChanges to indicate that the Activity does not restart when the screen rotates or changes size. Instead, it keeps running and is calledonConfigurationChanged()Methods. Here the DetailActivity does not have overrideonConfigurationChanged()Method, that is, when the screen rotates and sizes change, the DetailActivity keeps running without doing anything.
  3. Normally, the Activity restarts when configuration changes occur at runtime, and parameters in the Android :configChanges property specify some of these configuration changes by the ActivityonConfigurationChanged()Method, without the need to restart the Activity. For example, the keyboardHidden parameter represents a configuration change in the keyboard’s availability state. Placing it in the Android :configChanges property disables the automatic pop-up input method when you first enter your Activity. More information is availableThis Android Developers document.

Drawable Resources

In Addition to image files provided by PNG, JPG, GIF, etc., there are many resources provided directly by XML files on Android. For example, in InventoryApp, background_border. XML provides the list sub-items of CatalogActivity and the border background of DetailActivity image, which belongs to Shape Drawable; Image_chooser_item_color_list. XML provides the color of the options in the add picture dialog at different points by State, which belongs to the State List Drawable. The documentation of Drawable Resources is very detailed and the logic is not complicated, so I won’t repeat it here.

FloatingActionButton

The position of the FloatingActionButton can be anchored to a view, as shown in the figure above, with the sales button anchored to the bottom right corner of the goods image using the following code.

In list_item.xml

<android.support.design.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"

    ...>

    <LinearLayout .../>

    <android.support.design.widget.FloatingActionButton
        ...

        android:layout_margin="@dimen/activity_spacing"
        android:src="@drawable/ic_sell_white_24dp"
        app:layout_anchor="@id/item_image"
        app:layout_anchorGravity="bottom|right|end" />
</android.support.design.widget.CoordinatorLayout>
Copy the code
  1. CoordinatorLayout as the root directory, don’t forget to add the APP namespace.
  2. Add the app:layout_anchor property to the FloatingActionButton and take the view ID to anchor as an argument; Then add the app:layout_anchorGravity property and set the anchor position, in this case the lower right corner, and usually add a 16dp margin.
  3. Note that FloatingActionButton is a subclass of ImageButton, so you cannot add a text resource to FloatingActionButton by default.