/* * Copyright (c) 2016. Eli Connelly * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.emogoth.android.phone.mimi.util; import android.app.Activity; import android.app.Notification; import android.app.NotificationChannel; import android.app.NotificationManager; import android.app.PendingIntent; import android.content.Context; import android.content.Intent; import android.graphics.Bitmap; import android.net.Uri; import android.os.Build; import android.text.TextUtils; import android.util.Log; import android.webkit.MimeTypeMap; import android.widget.Toast; import androidx.core.app.NotificationCompat; import androidx.documentfile.provider.DocumentFile; import com.emogoth.android.phone.mimi.BuildConfig; import com.emogoth.android.phone.mimi.R; import com.emogoth.android.phone.mimi.app.MimiApplication; import com.emogoth.android.phone.mimi.autorefresh.RefreshJobService; import com.emogoth.android.phone.mimi.service.DownloadService; import com.google.android.material.dialog.MaterialAlertDialogBuilder; import java.io.Closeable; import java.io.EOFException; import java.io.File; import java.io.FilterInputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.io.Reader; import java.lang.reflect.InvocationTargetException; import java.net.URI; import java.util.Collections; import java.util.HashMap; import java.util.Map; import io.reactivex.SingleObserver; import io.reactivex.android.schedulers.AndroidSchedulers; import io.reactivex.disposables.Disposable; import io.reactivex.schedulers.Schedulers; import okio.Buffer; import okio.BufferedSink; import okio.Okio; import okio.Source; public class IOUtils { private static final int DEFAULT_BUFFER_SIZE = 1024 * 4; private static final int EOF = -1; private static final int SKIP_BUFFER_SIZE = 2048; private static byte[] SKIP_BYTE_BUFFER; /** * Simple wrapper around {@link java.io.InputStream#read()} that throws EOFException * instead of returning -1. */ public static int read(InputStream is) throws IOException { int b = is.read(); if (b == -1) { throw new EOFException(); } return b; } public static void writeInt(OutputStream os, int n) throws IOException { os.write((n >> 0) & 0xff); os.write((n >> 8) & 0xff); os.write((n >> 16) & 0xff); os.write((n >> 24) & 0xff); } public static int readInt(InputStream is) throws IOException { int n = 0; n |= (read(is) << 0); n |= (read(is) << 8); n |= (read(is) << 16); n |= (read(is) << 24); return n; } public static void writeLong(OutputStream os, long n) throws IOException { os.write((byte) (n >>> 0)); os.write((byte) (n >>> 8)); os.write((byte) (n >>> 16)); os.write((byte) (n >>> 24)); os.write((byte) (n >>> 32)); os.write((byte) (n >>> 40)); os.write((byte) (n >>> 48)); os.write((byte) (n >>> 56)); } public static long readLong(InputStream is) throws IOException { long n = 0; n |= ((read(is) & 0xFFL) << 0); n |= ((read(is) & 0xFFL) << 8); n |= ((read(is) & 0xFFL) << 16); n |= ((read(is) & 0xFFL) << 24); n |= ((read(is) & 0xFFL) << 32); n |= ((read(is) & 0xFFL) << 40); n |= ((read(is) & 0xFFL) << 48); n |= ((read(is) & 0xFFL) << 56); return n; } public static void writeString(OutputStream os, String s) throws IOException { byte[] b = s.getBytes("UTF-8"); writeLong(os, b.length); os.write(b, 0, b.length); } public static String readString(InputStream is) throws IOException { int n = (int) readLong(is); byte[] b = streamToBytes(is, n); return new String(b, "UTF-8"); } public static void writeStringStringMap(Map<String, String> map, OutputStream os) throws IOException { if (map != null) { writeInt(os, map.size()); for (Map.Entry<String, String> entry : map.entrySet()) { writeString(os, entry.getKey()); writeString(os, entry.getValue()); } } else { writeInt(os, 0); } } public static Map<String, String> readStringStringMap(InputStream is) throws IOException { int size = readInt(is); Map<String, String> result = (size == 0) ? Collections.<String, String>emptyMap() : new HashMap<String, String>(size); for (int i = 0; i < size; i++) { String key = readString(is).intern(); String value = readString(is).intern(); result.put(key, value); } return result; } /** * Reads the contents of an InputStream into a byte[]. */ public static byte[] streamToBytes(InputStream in, int length) throws IOException { byte[] bytes = new byte[length]; int count; int pos = 0; while (pos < length && ((count = in.read(bytes, pos, length - pos)) != -1)) { pos += count; } if (pos != length) { throw new IOException("Expected " + length + " bytes, read " + pos + " bytes"); } return bytes; } public static class CountingInputStream extends FilterInputStream { private int bytesRead = 0; public CountingInputStream(InputStream in) { super(in); } @Override public int read() throws IOException { int result = super.read(); if (result != -1) { bytesRead++; } return result; } @Override public int read(byte[] buffer, int offset, int count) throws IOException { int result = super.read(buffer, offset, count); if (result != -1) { bytesRead += result; } return result; } public long getBytesRead() { return bytesRead; } } /** * Copy bytes from a large (over 2GB) <code>InputStream</code> to an * <code>OutputStream</code>. * <p> * This method buffers the input internally, so there is no need to use a * <code>BufferedInputStream</code>. * <p> * The buffer size is given by {@link #DEFAULT_BUFFER_SIZE}. * * @param input the <code>InputStream</code> to read from * @param output the <code>OutputStream</code> to write to * @return the number of bytes copied * @throws NullPointerException if the input or output is null * @throws IOException if an I/O error occurs * @since 1.3 */ public static long copyLarge(InputStream input, OutputStream output) throws IOException { return copyLarge(input, output, new byte[DEFAULT_BUFFER_SIZE]); } /** * Copy bytes from a large (over 2GB) <code>InputStream</code> to an * <code>OutputStream</code>. * <p> * This method uses the provided buffer, so there is no need to use a * <code>BufferedInputStream</code>. * <p> * * @param input the <code>InputStream</code> to read from * @param output the <code>OutputStream</code> to write to * @param buffer the buffer to use for the copy * @return the number of bytes copied * @throws NullPointerException if the input or output is null * @throws IOException if an I/O error occurs * @since 2.2 */ public static long copyLarge(InputStream input, OutputStream output, byte[] buffer) throws IOException { long count = 0; int n = 0; while (EOF != (n = input.read(buffer))) { output.write(buffer, 0, n); count += n; } return count; } /** * Copy some or all bytes from a large (over 2GB) <code>InputStream</code> to an * <code>OutputStream</code>, optionally skipping input bytes. * <p> * This method buffers the input internally, so there is no need to use a * <code>BufferedInputStream</code>. * <p> * The buffer size is given by {@link #DEFAULT_BUFFER_SIZE}. * * @param input the <code>InputStream</code> to read from * @param output the <code>OutputStream</code> to write to * @param inputOffset : number of bytes to skip from input before copying * -ve values are ignored * @param length : number of bytes to copy. -ve means all * @return the number of bytes copied * @throws NullPointerException if the input or output is null * @throws IOException if an I/O error occurs * @since 2.2 */ public static long copyLarge(InputStream input, OutputStream output, long inputOffset, long length) throws IOException { return copyLarge(input, output, inputOffset, length, new byte[DEFAULT_BUFFER_SIZE]); } /** * Copy some or all bytes from a large (over 2GB) <code>InputStream</code> to an * <code>OutputStream</code>, optionally skipping input bytes. * <p> * This method uses the provided buffer, so there is no need to use a * <code>BufferedInputStream</code>. * <p> * * @param input the <code>InputStream</code> to read from * @param output the <code>OutputStream</code> to write to * @param inputOffset : number of bytes to skip from input before copying * -ve values are ignored * @param length : number of bytes to copy. -ve means all * @param buffer the buffer to use for the copy * @return the number of bytes copied * @throws NullPointerException if the input or output is null * @throws IOException if an I/O error occurs * @since 2.2 */ public static long copyLarge(InputStream input, OutputStream output, final long inputOffset, final long length, byte[] buffer) throws IOException { if (inputOffset > 0) { skipFully(input, inputOffset); } if (length == 0) { return 0; } final int bufferLength = buffer.length; int bytesToRead = bufferLength; if (length > 0 && length < bufferLength) { bytesToRead = (int) length; } int read; long totalRead = 0; while (bytesToRead > 0 && EOF != (read = input.read(buffer, 0, bytesToRead))) { output.write(buffer, 0, read); totalRead += read; if (length > 0) { // only adjust length if not reading to the end // Note the cast must work because buffer.length is an integer bytesToRead = (int) Math.min(length - totalRead, bufferLength); } } return totalRead; } /** * Skip bytes from an input byte stream. * This implementation guarantees that it will read as many bytes * as possible before giving up; this may not always be the case for * subclasses of {@link Reader}. * * @param input byte stream to skip * @param toSkip number of bytes to skip. * @return number of bytes actually skipped. * @throws IOException if there is a problem reading the file * @throws IllegalArgumentException if toSkip is negative * @see InputStream#skip(long) * @since 2.0 */ public static long skip(InputStream input, long toSkip) throws IOException { if (toSkip < 0) { throw new IllegalArgumentException("Skip count must be non-negative, actual: " + toSkip); } /* * N.B. no need to synchronize this because: - we don't care if the buffer is created multiple times (the data * is ignored) - we always use the same size buffer, so if it it is recreated it will still be OK (if the buffer * size were variable, we would need to synch. to ensure some other thread did not create a smaller one) */ if (SKIP_BYTE_BUFFER == null) { SKIP_BYTE_BUFFER = new byte[SKIP_BUFFER_SIZE]; } long remain = toSkip; while (remain > 0) { long n = input.read(SKIP_BYTE_BUFFER, 0, (int) Math.min(remain, SKIP_BUFFER_SIZE)); if (n < 0) { // EOF break; } remain -= n; } return toSkip - remain; } /** * Skip the requested number of bytes or fail if there are not enough left. * <p> * This allows for the possibility that {@link InputStream#skip(long)} may * not skip as many bytes as requested (most likely because of reaching EOF). * * @param input stream to skip * @param toSkip the number of bytes to skip * @throws IOException if there is a problem reading the file * @throws IllegalArgumentException if toSkip is negative * @throws EOFException if the number of bytes skipped was incorrect * @see InputStream#skip(long) * @since 2.0 */ public static void skipFully(InputStream input, long toSkip) throws IOException { if (toSkip < 0) { throw new IllegalArgumentException("Bytes to skip must not be negative: " + toSkip); } long skipped = skip(input, toSkip); if (skipped != toSkip) { throw new EOFException("Bytes to skip: " + toSkip + " actual: " + skipped); } } /** * Unconditionally close a <code>Closeable</code>. * <p> * Equivalent to {@link Closeable#close()}, except any exceptions will be ignored. * This is typically used in finally blocks. * <p> * Example code: * <pre> * Closeable closeable = null; * try { * closeable = new FileReader("foo.txt"); * // process closeable * closeable.close(); * } catch (Exception e) { * // error handling * } finally { * IOUtils.closeQuietly(closeable); * } * </pre> * * @param closeable the object to close, may be null or already closed * @since 2.0 */ public static void closeQuietly(Closeable closeable) { try { if (closeable != null) { closeable.close(); } } catch (IOException ioe) { // ignore } } private static final int NOTIFICATION_ID = 87; private static final int ACTION_CANCEL = 1; private static final int ACTION_OVERWITE = 2; private static final int ACTION_RENAME = 3; public static final int REQUEST_CODE_DIR_CHOOSER_PERSISTENT = 44; private static final String LOG_TAG = "IOUtils"; public static void safeSaveFile(final Activity activity, final DocumentFile saveDir, final File localFile, final String saveFileName, final boolean showNotification) { if (saveDir != null && saveDir.canWrite()) { Uri path; try { path = MimiUtil.getDocumentFileRealPath(saveDir); } catch (NoSuchMethodException | NoSuchFieldException | InvocationTargetException | IllegalAccessException e) { Log.e(LOG_TAG, "Error getting real path from DocumentFile", e); return; } if (path == null) { return; } final int fileExtBeginIndex = saveFileName.indexOf("."); if (fileExtBeginIndex < 0) { return; } final String fileName = saveFileName.substring(0, fileExtBeginIndex); final String fileExt = saveFileName.substring(fileExtBeginIndex + 1); DocumentFile potentialFile = DocumentFile.fromFile(new File(path.getPath() + "/" + fileName + "." + fileExt)); if (potentialFile.exists()) { final MaterialAlertDialogBuilder dialogBuilder = new MaterialAlertDialogBuilder(activity); dialogBuilder.setTitle(R.string.copy_file) .setMessage(R.string.file_name_is_taken) .setNegativeButton(R.string.cancel, (dialog, which) -> dialog.dismiss()) .setNeutralButton(R.string.overwrite, (dialog, which) -> saveFileWithRetry(saveDir, localFile, saveFileName, showNotification, ACTION_OVERWITE, 2)) .setPositiveButton(R.string.rename, (dialog, which) -> saveFileWithRetry(saveDir, localFile, saveFileName, showNotification, ACTION_RENAME, 2)) .show(); } else { saveFileWithRetry(saveDir, localFile, saveFileName, showNotification, ACTION_OVERWITE, 2); } } else { final MaterialAlertDialogBuilder dialogBuilder = new MaterialAlertDialogBuilder(activity); dialogBuilder.setTitle(R.string.requesting_permissions) .setMessage(R.string.save_permissions_message) .setNegativeButton(R.string.cancel, (dialogInterface, i) -> dialogInterface.cancel()) .setPositiveButton(R.string.ok, (dialogInterface, i) -> { Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE); intent.addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION); intent.addFlags(Intent.FLAG_GRANT_PREFIX_URI_PERMISSION); intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); intent.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION); activity.startActivityForResult(intent, REQUEST_CODE_DIR_CHOOSER_PERSISTENT); }) .setCancelable(true) .show(); } } public static boolean saveFileWithRetry(final DocumentFile dir, final File filePath, final String saveFileName, final boolean showNotification, final int action, final int retries) { boolean success = false; int count = 0; while (!success && count < retries) { count++; success = saveFile(dir, filePath, saveFileName, showNotification, action); } return success; } public static boolean saveFile(final DocumentFile dir, final File filePath, final String saveFileName, final boolean showNotification, final int action) { try { final DocumentFile saveDir; if (dir == null) { saveDir = MimiUtil.getSaveDir(); } else { saveDir = dir; } if (filePath != null) { final int fileExtBeginIndex = saveFileName.indexOf("."); if (fileExtBeginIndex < 0) { return false; } String fileName = saveFileName.substring(0, fileExtBeginIndex); final String fileExt = saveFileName.substring(fileExtBeginIndex + 1); String mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(fileExt); Uri path; try { path = MimiUtil.getDocumentFileRealPath(dir); } catch (NoSuchMethodException | InvocationTargetException | IllegalAccessException e) { return false; } if (path == null) { return false; } File fileLocation = new File(path.getPath() + "/" + fileName + "." + fileExt); DocumentFile searchFile = DocumentFile.fromFile(fileLocation); if (searchFile.exists() && action == ACTION_CANCEL) { return false; } DocumentFile writeFile = null; if (action == ACTION_RENAME) { StringBuilder renamedFile = new StringBuilder(fileName); int i = 1; while (searchFile.exists()) { renamedFile = new StringBuilder(fileName).append("(").append(i).append(")"); fileLocation = new File(path.getPath() + "/" + renamedFile + "." + fileExt); searchFile = DocumentFile.fromFile(fileLocation); i++; } fileName = renamedFile.toString(); } writeFile = saveDir.createFile(mimeType, fileName); if (writeFile == null) { Log.e(LOG_TAG, "Could not write file " + fileName); return false; } final Context context = MimiApplication.getInstance().getApplicationContext(); if (copyFile(filePath, writeFile.getUri())) { try { if (!TextUtils.isEmpty(writeFile.getName()) && !writeFile.getName().equals(fileName + "." + fileExt)) { writeFile.renameTo(fileName + "." + fileExt); } if (writeFile.length() > 0) { Toast.makeText(context, R.string.file_saved, Toast.LENGTH_LONG).show(); } else { try { filePath.delete(); } catch (Exception e) { // no op } return false; } if (showNotification) { final DocumentFile documentOfImage = writeFile; MimiUtil.scaleBitmap(filePath) .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe(new SingleObserver<Bitmap>() { @Override public void onSubscribe(Disposable d) { } @Override public void onSuccess(Bitmap bitmap) { showSaveNotification(context, bitmap, documentOfImage, fileExt); try { filePath.delete(); } catch (Exception e) { // no op } } @Override public void onError(Throwable e) { Log.e(LOG_TAG, "Error scaling bitmap", e); try { filePath.delete(); } catch (Exception ex) { // no op } } }); } new SingleMediaScanner(context, fileLocation, (s, uri) -> { }); } catch (Exception e) { Log.e(LOG_TAG, "Error writing file", e); return false; } } } else { Toast.makeText(MimiApplication.getInstance().getApplicationContext(), R.string.failed_to_save_file, Toast.LENGTH_LONG).show(); } } catch (final Exception e) { e.printStackTrace(); } return true; } private static void showSaveNotification(final Context context, final Bitmap bmp, final DocumentFile destPath, final String fileExt) { if (context == null) { return; } final String type; if (fileExt != null && fileExt.equalsIgnoreCase(".webm")) { type = "video/*"; } else { type = "image/*"; } Uri uriToImage = null; if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) { uriToImage = destPath.getUri(); } else { try { Uri realPath = MimiUtil.getDocumentFileRealPath(destPath); URI fileUri = URI.create(realPath.toString()); uriToImage = MimiUtil.getFileProvider(new File(fileUri)); } catch (NoSuchMethodException | NoSuchFieldException | InvocationTargetException | IllegalAccessException e) { Log.e(LOG_TAG, "Error getting real path from DocumentFile", e); } } if (uriToImage == null) { uriToImage = destPath.getUri(); } try { final Intent contentIntent = new Intent(); contentIntent.setAction(Intent.ACTION_VIEW); contentIntent.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); contentIntent.setDataAndType(uriToImage, type); final PendingIntent pendingContentIntent = PendingIntent.getActivity(context, 0, contentIntent, 0); final Intent shareIntent = new Intent(); shareIntent.setAction(Intent.ACTION_SEND); shareIntent.setDataAndType(uriToImage, type); shareIntent.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); shareIntent.putExtra(Intent.EXTRA_STREAM, uriToImage); final PendingIntent pendingShareIntent = PendingIntent.getActivity(context, 0, shareIntent, 0); NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); final NotificationCompat.Builder builder = new NotificationCompat.Builder(context); builder.setContentTitle(context.getString(R.string.file_saved)); builder.setContentText(destPath.getName()); builder.setSubText(MimiUtil.humanReadableByteCount(destPath.length(), true)); builder.setSmallIcon(R.drawable.ic_notification_photo); builder.setLargeIcon(bmp); builder.setStyle(new NotificationCompat.BigPictureStyle() .bigPicture(bmp)); builder.setContentIntent(pendingContentIntent); builder.addAction(R.drawable.ic_notification_share, context.getString(R.string.share), pendingShareIntent); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { String channelName = context.getString(R.string.mimi_file_downloader); NotificationChannel saveFileChannel = new NotificationChannel(DownloadService.DOWNLOADER_CHANNEL_ID, channelName, NotificationManager.IMPORTANCE_LOW); builder.setChannelId(DownloadService.DOWNLOADER_CHANNEL_ID); notificationManager.createNotificationChannel(saveFileChannel); } final Notification saveFileNotification = builder.build(); notificationManager.notify(RefreshJobService.NOTIFICATION_ID, saveFileNotification); } catch (Exception e) { Log.e(LOG_TAG, "Error creating notification", e); } } public static boolean copyFile(final File copyFrom, final Uri copyTo) { final Context context = MimiApplication.getInstance().getApplicationContext(); if (context == null) { return false; } BufferedSink sink = null; Source source = null; Buffer sinkBuffer = null; try { sink = Okio.buffer(Okio.sink(context.getContentResolver().openOutputStream(copyTo))); source = Okio.source(copyFrom); sinkBuffer = sink.buffer(); long count = 0; while (count != -1) { count = source.read(sinkBuffer, 1024L); sink.emit(); } } catch (IOException | NullPointerException e) { Log.e(LOG_TAG, "Error copying file", e); return false; } finally { try { Log.d(LOG_TAG, "Flushing and closing after writing file"); source.close(); sink.flush(); sink.close(); } catch (Exception e) { Log.e(LOG_TAG, "Error finalizing file copy", e); } } return true; } }