diff options
author | Bobby Georgescu <georgescu@google.com> | 2014-05-14 10:19:19 -0700 |
---|---|---|
committer | Bobby Georgescu <georgescu@google.com> | 2014-05-14 18:01:29 +0000 |
commit | f640d379259bb114a50e3200f49961b89d60f2c2 (patch) | |
tree | 9a76ad6315f4f77063dda28b7441e2904ca3cd04 /src/com/android/gallery3d/ingest/data | |
parent | 29e13812d006579106c147f87c859aec23dfbe11 (diff) | |
download | android_packages_apps_Gallery2-f640d379259bb114a50e3200f49961b89d60f2c2.tar.gz android_packages_apps_Gallery2-f640d379259bb114a50e3200f49961b89d60f2c2.tar.bz2 android_packages_apps_Gallery2-f640d379259bb114a50e3200f49961b89d60f2c2.zip |
Update ingest importer code
Change-Id: I0f3b0809deead2f49501a5309f0ddab9c911274f
Diffstat (limited to 'src/com/android/gallery3d/ingest/data')
9 files changed, 1396 insertions, 73 deletions
diff --git a/src/com/android/gallery3d/ingest/data/BitmapWithMetadata.java b/src/com/android/gallery3d/ingest/data/BitmapWithMetadata.java index bbc90f670..c436fa7b4 100644 --- a/src/com/android/gallery3d/ingest/data/BitmapWithMetadata.java +++ b/src/com/android/gallery3d/ingest/data/BitmapWithMetadata.java @@ -16,14 +16,20 @@ package com.android.gallery3d.ingest.data; +import android.annotation.TargetApi; import android.graphics.Bitmap; +import android.os.Build; +/** + * Encapsulates a Bitmap and some additional metadata. + */ +@TargetApi(Build.VERSION_CODES.HONEYCOMB_MR1) public class BitmapWithMetadata { - public Bitmap bitmap; - public int rotationDegrees; + public Bitmap bitmap; + public int rotationDegrees; - public BitmapWithMetadata(Bitmap bitmap, int rotationDegrees) { - this.bitmap = bitmap; - this.rotationDegrees = rotationDegrees; - } + public BitmapWithMetadata(Bitmap bitmap, int rotationDegrees) { + this.bitmap = bitmap; + this.rotationDegrees = rotationDegrees; + } } diff --git a/src/com/android/gallery3d/ingest/data/DateBucket.java b/src/com/android/gallery3d/ingest/data/DateBucket.java new file mode 100644 index 000000000..85eedb3bd --- /dev/null +++ b/src/com/android/gallery3d/ingest/data/DateBucket.java @@ -0,0 +1,63 @@ +package com.android.gallery3d.ingest.data; + +import android.annotation.TargetApi; +import android.os.Build; + +/** + * Date bucket for {@link MtpDeviceIndex}. + * See {@link MtpDeviceIndexRunnable} for implementation notes. + */ +@TargetApi(Build.VERSION_CODES.HONEYCOMB_MR1) +class DateBucket implements Comparable<DateBucket> { + final SimpleDate date; + final int unifiedStartIndex; + final int unifiedEndIndex; + final int itemsStartIndex; + final int numItems; + + public DateBucket(SimpleDate date, int unifiedStartIndex, int unifiedEndIndex, + int itemsStartIndex, int numItems) { + this.date = date; + this.unifiedStartIndex = unifiedStartIndex; + this.unifiedEndIndex = unifiedEndIndex; + this.itemsStartIndex = itemsStartIndex; + this.numItems = numItems; + } + + @Override + public String toString() { + return date.toString(); + } + + @Override + public int hashCode() { + return date.hashCode(); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null) { + return false; + } + if (!(obj instanceof DateBucket)) { + return false; + } + DateBucket other = (DateBucket) obj; + if (date == null) { + if (other.date != null) { + return false; + } + } else if (!date.equals(other.date)) { + return false; + } + return true; + } + + @Override + public int compareTo(DateBucket another) { + return this.date.compareTo(another.date); + } +}
\ No newline at end of file diff --git a/src/com/android/gallery3d/ingest/data/ImportTask.java b/src/com/android/gallery3d/ingest/data/ImportTask.java new file mode 100644 index 000000000..ee2a7d0e5 --- /dev/null +++ b/src/com/android/gallery3d/ingest/data/ImportTask.java @@ -0,0 +1,121 @@ +/* + * Copyright (C) 2013 The Android Open Source Project + * + * 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.android.gallery3d.ingest.data; + +import android.annotation.TargetApi; +import android.content.Context; +import android.mtp.MtpDevice; +import android.os.Build; +import android.os.Environment; +import android.os.PowerManager; +import android.os.StatFs; +import android.util.Log; + +import java.io.File; +import java.util.Collection; +import java.util.LinkedList; +import java.util.List; + +/** + * Task that handles the copying of items from an MTP device. + */ +@TargetApi(Build.VERSION_CODES.HONEYCOMB_MR1) +public class ImportTask implements Runnable { + + private static final String TAG = "ImportTask"; + + /** + * Import progress listener. + */ + public interface Listener { + void onImportProgress(int visitedCount, int totalCount, String pathIfSuccessful); + + void onImportFinish(Collection<IngestObjectInfo> objectsNotImported, int visitedCount); + } + + private static final String WAKELOCK_LABEL = "Google Photos MTP Import Task"; + + private Listener mListener; + private String mDestAlbumName; + private Collection<IngestObjectInfo> mObjectsToImport; + private MtpDevice mDevice; + private PowerManager.WakeLock mWakeLock; + + public ImportTask(MtpDevice device, Collection<IngestObjectInfo> objectsToImport, + String destAlbumName, Context context) { + mDestAlbumName = destAlbumName; + mObjectsToImport = objectsToImport; + mDevice = device; + PowerManager pm = (PowerManager) context.getSystemService(Context.POWER_SERVICE); + mWakeLock = pm.newWakeLock(PowerManager.SCREEN_DIM_WAKE_LOCK, WAKELOCK_LABEL); + } + + public void setListener(Listener listener) { + mListener = listener; + } + + @Override + public void run() { + mWakeLock.acquire(); + try { + List<IngestObjectInfo> objectsNotImported = new LinkedList<IngestObjectInfo>(); + int visited = 0; + int total = mObjectsToImport.size(); + mListener.onImportProgress(visited, total, null); + File dest = new File(Environment.getExternalStorageDirectory(), mDestAlbumName); + dest.mkdirs(); + for (IngestObjectInfo object : mObjectsToImport) { + visited++; + String importedPath = null; + if (hasSpaceForSize(object.getCompressedSize())) { + importedPath = new File(dest, object.getName(mDevice)).getAbsolutePath(); + if (!mDevice.importFile(object.getObjectHandle(), importedPath)) { + importedPath = null; + } + } + if (importedPath == null) { + objectsNotImported.add(object); + } + if (mListener != null) { + mListener.onImportProgress(visited, total, importedPath); + } + } + if (mListener != null) { + mListener.onImportFinish(objectsNotImported, visited); + } + } finally { + mListener = null; + mWakeLock.release(); + } + } + + private static boolean hasSpaceForSize(long size) { + String state = Environment.getExternalStorageState(); + if (!Environment.MEDIA_MOUNTED.equals(state)) { + return false; + } + + String path = Environment.getExternalStorageDirectory().getPath(); + try { + StatFs stat = new StatFs(path); + return stat.getAvailableBlocks() * (long) stat.getBlockSize() > size; + } catch (Exception e) { + Log.i(TAG, "Fail to access external storage", e); + } + return false; + } +} diff --git a/src/com/android/gallery3d/ingest/data/IngestObjectInfo.java b/src/com/android/gallery3d/ingest/data/IngestObjectInfo.java new file mode 100644 index 000000000..25273838b --- /dev/null +++ b/src/com/android/gallery3d/ingest/data/IngestObjectInfo.java @@ -0,0 +1,114 @@ +package com.android.gallery3d.ingest.data; + +import android.annotation.TargetApi; +import android.mtp.MtpDevice; +import android.mtp.MtpObjectInfo; +import android.os.Build; + +/** + * Holds the info needed for the in-memory index of MTP objects. + */ +@TargetApi(Build.VERSION_CODES.HONEYCOMB_MR1) +public class IngestObjectInfo implements Comparable<IngestObjectInfo> { + + private int mHandle; + private long mDateCreated; + private int mFormat; + private int mCompressedSize; + + public IngestObjectInfo(MtpObjectInfo mtpObjectInfo) { + mHandle = mtpObjectInfo.getObjectHandle(); + mDateCreated = mtpObjectInfo.getDateCreated(); + mFormat = mtpObjectInfo.getFormat(); + mCompressedSize = mtpObjectInfo.getCompressedSize(); + } + + public IngestObjectInfo(int handle, long dateCreated, int format, int compressedSize) { + mHandle = handle; + mDateCreated = dateCreated; + mFormat = format; + mCompressedSize = compressedSize; + } + + public int getCompressedSize() { + return mCompressedSize; + } + + public int getFormat() { + return mFormat; + } + + public long getDateCreated() { + return mDateCreated; + } + + public int getObjectHandle() { + return mHandle; + } + + public String getName(MtpDevice device) { + if (device != null) { + MtpObjectInfo info = device.getObjectInfo(mHandle); + if (info != null) { + return info.getName(); + } + } + return null; + } + + @Override + public int compareTo(IngestObjectInfo another) { + long diff = getDateCreated() - another.getDateCreated(); + if (diff < 0) { + return -1; + } else if (diff == 0) { + return 0; + } else { + return 1; + } + } + + @Override + public String toString() { + return "IngestObjectInfo [mHandle=" + mHandle + ", mDateCreated=" + mDateCreated + + ", mFormat=" + mFormat + ", mCompressedSize=" + mCompressedSize + "]"; + } + + @Override + public int hashCode() { + final int prime = 31; + int result = 1; + result = prime * result + mCompressedSize; + result = prime * result + (int) (mDateCreated ^ (mDateCreated >>> 32)); + result = prime * result + mFormat; + result = prime * result + mHandle; + return result; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null) { + return false; + } + if (!(obj instanceof IngestObjectInfo)) { + return false; + } + IngestObjectInfo other = (IngestObjectInfo) obj; + if (mCompressedSize != other.mCompressedSize) { + return false; + } + if (mDateCreated != other.mDateCreated) { + return false; + } + if (mFormat != other.mFormat) { + return false; + } + if (mHandle != other.mHandle) { + return false; + } + return true; + } +} diff --git a/src/com/android/gallery3d/ingest/data/MtpBitmapFetch.java b/src/com/android/gallery3d/ingest/data/MtpBitmapFetch.java index c6504a561..3295828f1 100644 --- a/src/com/android/gallery3d/ingest/data/MtpBitmapFetch.java +++ b/src/com/android/gallery3d/ingest/data/MtpBitmapFetch.java @@ -16,91 +16,98 @@ package com.android.gallery3d.ingest.data; +import android.annotation.TargetApi; import android.content.Context; import android.graphics.Bitmap; import android.graphics.BitmapFactory; import android.mtp.MtpDevice; -import android.mtp.MtpObjectInfo; +import android.os.Build; import android.util.DisplayMetrics; import android.view.WindowManager; import com.android.gallery3d.data.Exif; import com.android.photos.data.GalleryBitmapPool; +/** + * Helper class for fetching bitmaps from MTP devices. + */ +@TargetApi(Build.VERSION_CODES.HONEYCOMB_MR1) public class MtpBitmapFetch { - private static int sMaxSize = 0; + private static int sMaxSize = 0; - public static void recycleThumbnail(Bitmap b) { - if (b != null) { - GalleryBitmapPool.getInstance().put(b); - } + public static void recycleThumbnail(Bitmap b) { + if (b != null) { + GalleryBitmapPool.getInstance().put(b); } + } - public static Bitmap getThumbnail(MtpDevice device, MtpObjectInfo info) { - byte[] imageBytes = device.getThumbnail(info.getObjectHandle()); - if (imageBytes == null) { - return null; - } - BitmapFactory.Options o = new BitmapFactory.Options(); - o.inJustDecodeBounds = true; - BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.length, o); - if (o.outWidth == 0 || o.outHeight == 0) { - return null; - } - o.inBitmap = GalleryBitmapPool.getInstance().get(o.outWidth, o.outHeight); - o.inMutable = true; - o.inJustDecodeBounds = false; - o.inSampleSize = 1; - try { - return BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.length, o); - } catch (IllegalArgumentException e) { - // BitmapFactory throws an exception rather than returning null - // when image decoding fails and an existing bitmap was supplied - // for recycling, even if the failure was not caused by the use - // of that bitmap. - return BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.length); - } + public static Bitmap getThumbnail(MtpDevice device, IngestObjectInfo info) { + byte[] imageBytes = device.getThumbnail(info.getObjectHandle()); + if (imageBytes == null) { + return null; } - - public static BitmapWithMetadata getFullsize(MtpDevice device, MtpObjectInfo info) { - return getFullsize(device, info, sMaxSize); + BitmapFactory.Options o = new BitmapFactory.Options(); + o.inJustDecodeBounds = true; + BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.length, o); + if (o.outWidth == 0 || o.outHeight == 0) { + return null; } + o.inBitmap = GalleryBitmapPool.getInstance().get(o.outWidth, o.outHeight); + o.inMutable = true; + o.inJustDecodeBounds = false; + o.inSampleSize = 1; + try { + return BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.length, o); + } catch (IllegalArgumentException e) { + // BitmapFactory throws an exception rather than returning null + // when image decoding fails and an existing bitmap was supplied + // for recycling, even if the failure was not caused by the use + // of that bitmap. + return BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.length); + } + } - public static BitmapWithMetadata getFullsize(MtpDevice device, MtpObjectInfo info, int maxSide) { - byte[] imageBytes = device.getObject(info.getObjectHandle(), info.getCompressedSize()); - if (imageBytes == null) { - return null; - } - Bitmap created; - if (maxSide > 0) { - BitmapFactory.Options o = new BitmapFactory.Options(); - o.inJustDecodeBounds = true; - BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.length, o); - int w = o.outWidth; - int h = o.outHeight; - int comp = Math.max(h, w); - int sampleSize = 1; - while ((comp >> 1) >= maxSide) { - comp = comp >> 1; - sampleSize++; - } - o.inSampleSize = sampleSize; - o.inJustDecodeBounds = false; - created = BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.length, o); - } else { - created = BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.length); - } - if (created == null) { - return null; - } + public static BitmapWithMetadata getFullsize(MtpDevice device, IngestObjectInfo info) { + return getFullsize(device, info, sMaxSize); + } - return new BitmapWithMetadata(created, Exif.getOrientation(imageBytes)); + public static BitmapWithMetadata getFullsize(MtpDevice device, IngestObjectInfo info, + int maxSide) { + byte[] imageBytes = device.getObject(info.getObjectHandle(), info.getCompressedSize()); + if (imageBytes == null) { + return null; } - - public static void configureForContext(Context context) { - DisplayMetrics metrics = new DisplayMetrics(); - WindowManager wm = (WindowManager)context.getSystemService(Context.WINDOW_SERVICE); - wm.getDefaultDisplay().getMetrics(metrics); - sMaxSize = Math.max(metrics.heightPixels, metrics.widthPixels); + Bitmap created; + if (maxSide > 0) { + BitmapFactory.Options o = new BitmapFactory.Options(); + o.inJustDecodeBounds = true; + BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.length, o); + int w = o.outWidth; + int h = o.outHeight; + int comp = Math.max(h, w); + int sampleSize = 1; + while ((comp >> 1) >= maxSide) { + comp = comp >> 1; + sampleSize++; + } + o.inSampleSize = sampleSize; + o.inJustDecodeBounds = false; + created = BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.length, o); + } else { + created = BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.length); } + if (created == null) { + return null; + } + + int orientation = Exif.getOrientation(imageBytes); + return new BitmapWithMetadata(created, orientation); + } + + public static void configureForContext(Context context) { + DisplayMetrics metrics = new DisplayMetrics(); + WindowManager wm = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE); + wm.getDefaultDisplay().getMetrics(metrics); + sMaxSize = Math.max(metrics.heightPixels, metrics.widthPixels); + } } diff --git a/src/com/android/gallery3d/ingest/data/MtpClient.java b/src/com/android/gallery3d/ingest/data/MtpClient.java new file mode 100644 index 000000000..cc6c9ce07 --- /dev/null +++ b/src/com/android/gallery3d/ingest/data/MtpClient.java @@ -0,0 +1,266 @@ +/* + * Copyright (C) 2010 The Android Open Source Project + * + * 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.android.gallery3d.ingest.data; + +import android.annotation.TargetApi; +import android.app.PendingIntent; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.hardware.usb.UsbConstants; +import android.hardware.usb.UsbDevice; +import android.hardware.usb.UsbDeviceConnection; +import android.hardware.usb.UsbInterface; +import android.hardware.usb.UsbManager; +import android.mtp.MtpDevice; +import android.os.Build; +import android.util.Log; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; + +/** + * This class helps an application manage a list of connected MTP or PTP devices. + * It listens for MTP devices being attached and removed from the USB host bus + * and notifies the application when the MTP device list changes. + */ +@TargetApi(Build.VERSION_CODES.HONEYCOMB_MR1) +public class MtpClient { + + private static final String TAG = "MtpClient"; + + private static final String ACTION_USB_PERMISSION = + "com.android.gallery3d.ingest.action.USB_PERMISSION"; + + private final Context mContext; + private final UsbManager mUsbManager; + private final ArrayList<Listener> mListeners = new ArrayList<Listener>(); + // mDevices contains all MtpDevices that have been seen by our client, + // so we can inform when the device has been detached. + // mDevices is also used for synchronization in this class. + private final HashMap<String, MtpDevice> mDevices = new HashMap<String, MtpDevice>(); + // List of MTP devices we should not try to open for which we are currently + // asking for permission to open. + private final ArrayList<String> mRequestPermissionDevices = new ArrayList<String>(); + // List of MTP devices we should not try to open. + // We add devices to this list if the user canceled a permission request or we were + // unable to open the device. + private final ArrayList<String> mIgnoredDevices = new ArrayList<String>(); + + private final PendingIntent mPermissionIntent; + + private final BroadcastReceiver mUsbReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + String action = intent.getAction(); + UsbDevice usbDevice = (UsbDevice) intent.getParcelableExtra(UsbManager.EXTRA_DEVICE); + String deviceName = usbDevice.getDeviceName(); + + synchronized (mDevices) { + MtpDevice mtpDevice = mDevices.get(deviceName); + + if (UsbManager.ACTION_USB_DEVICE_ATTACHED.equals(action)) { + if (mtpDevice == null) { + mtpDevice = openDeviceLocked(usbDevice); + } + if (mtpDevice != null) { + for (Listener listener : mListeners) { + listener.deviceAdded(mtpDevice); + } + } + } else if (UsbManager.ACTION_USB_DEVICE_DETACHED.equals(action)) { + if (mtpDevice != null) { + mDevices.remove(deviceName); + mRequestPermissionDevices.remove(deviceName); + mIgnoredDevices.remove(deviceName); + for (Listener listener : mListeners) { + listener.deviceRemoved(mtpDevice); + } + } + } else if (ACTION_USB_PERMISSION.equals(action)) { + mRequestPermissionDevices.remove(deviceName); + boolean permission = intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, + false); + Log.d(TAG, "ACTION_USB_PERMISSION: " + permission); + if (permission) { + if (mtpDevice == null) { + mtpDevice = openDeviceLocked(usbDevice); + } + if (mtpDevice != null) { + for (Listener listener : mListeners) { + listener.deviceAdded(mtpDevice); + } + } + } else { + // so we don't ask for permission again + mIgnoredDevices.add(deviceName); + } + } + } + } + }; + + /** + * An interface for being notified when MTP or PTP devices are attached + * or removed. In the current implementation, only PTP devices are supported. + */ + public interface Listener { + /** + * Called when a new device has been added + * + * @param device the new device that was added + */ + public void deviceAdded(MtpDevice device); + + /** + * Called when a new device has been removed + * + * @param device the device that was removed + */ + public void deviceRemoved(MtpDevice device); + } + + /** + * Tests to see if a {@link android.hardware.usb.UsbDevice} + * supports the PTP protocol (typically used by digital cameras) + * + * @param device the device to test + * @return true if the device is a PTP device. + */ + public static boolean isCamera(UsbDevice device) { + int count = device.getInterfaceCount(); + for (int i = 0; i < count; i++) { + UsbInterface intf = device.getInterface(i); + if (intf.getInterfaceClass() == UsbConstants.USB_CLASS_STILL_IMAGE && + intf.getInterfaceSubclass() == 1 && + intf.getInterfaceProtocol() == 1) { + return true; + } + } + return false; + } + + /** + * MtpClient constructor + * + * @param context the {@link android.content.Context} to use for the MtpClient + */ + public MtpClient(Context context) { + mContext = context; + mUsbManager = (UsbManager) context.getSystemService(Context.USB_SERVICE); + mPermissionIntent = PendingIntent.getBroadcast(mContext, 0, + new Intent(ACTION_USB_PERMISSION), 0); + IntentFilter filter = new IntentFilter(ACTION_USB_PERMISSION); + filter.addAction(UsbManager.ACTION_USB_DEVICE_ATTACHED); + filter.addAction(UsbManager.ACTION_USB_DEVICE_DETACHED); + filter.addAction(ACTION_USB_PERMISSION); + context.registerReceiver(mUsbReceiver, filter); + } + + /** + * Opens the {@link android.hardware.usb.UsbDevice} for an MTP or PTP + * device and return an {@link android.mtp.MtpDevice} for it. + * + * @param usbDevice the device to open + * @return an MtpDevice for the device. + */ + private MtpDevice openDeviceLocked(UsbDevice usbDevice) { + String deviceName = usbDevice.getDeviceName(); + + // don't try to open devices that we have decided to ignore + // or are currently asking permission for + if (isCamera(usbDevice) && !mIgnoredDevices.contains(deviceName) + && !mRequestPermissionDevices.contains(deviceName)) { + if (!mUsbManager.hasPermission(usbDevice)) { + mUsbManager.requestPermission(usbDevice, mPermissionIntent); + mRequestPermissionDevices.add(deviceName); + } else { + UsbDeviceConnection connection = mUsbManager.openDevice(usbDevice); + if (connection != null) { + MtpDevice mtpDevice = new MtpDevice(usbDevice); + if (mtpDevice.open(connection)) { + mDevices.put(usbDevice.getDeviceName(), mtpDevice); + return mtpDevice; + } else { + // so we don't try to open it again + mIgnoredDevices.add(deviceName); + } + } else { + // so we don't try to open it again + mIgnoredDevices.add(deviceName); + } + } + } + return null; + } + + /** + * Closes all resources related to the MtpClient object + */ + public void close() { + mContext.unregisterReceiver(mUsbReceiver); + } + + /** + * Registers a {@link com.android.gallery3d.data.MtpClient.Listener} interface to receive + * notifications when MTP or PTP devices are added or removed. + * + * @param listener the listener to register + */ + public void addListener(Listener listener) { + synchronized (mDevices) { + if (!mListeners.contains(listener)) { + mListeners.add(listener); + } + } + } + + /** + * Unregisters a {@link com.android.gallery3d.data.MtpClient.Listener} interface. + * + * @param listener the listener to unregister + */ + public void removeListener(Listener listener) { + synchronized (mDevices) { + mListeners.remove(listener); + } + } + + + /** + * Retrieves a list of all currently connected {@link android.mtp.MtpDevice}. + * + * @return the list of MtpDevices + */ + public List<MtpDevice> getDeviceList() { + synchronized (mDevices) { + // Query the USB manager since devices might have attached + // before we added our listener. + for (UsbDevice usbDevice : mUsbManager.getDeviceList().values()) { + if (mDevices.get(usbDevice.getDeviceName()) == null) { + openDeviceLocked(usbDevice); + } + } + + return new ArrayList<MtpDevice>(mDevices.values()); + } + } + + +} diff --git a/src/com/android/gallery3d/ingest/data/MtpDeviceIndex.java b/src/com/android/gallery3d/ingest/data/MtpDeviceIndex.java new file mode 100644 index 000000000..b21ad8355 --- /dev/null +++ b/src/com/android/gallery3d/ingest/data/MtpDeviceIndex.java @@ -0,0 +1,433 @@ +package com.android.gallery3d.ingest.data; + +import android.annotation.TargetApi; +import android.mtp.MtpConstants; +import android.mtp.MtpDevice; +import android.os.Build; + +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + +/** + * Index of MTP media objects organized into "buckets," or groupings, based on the date + * they were created. + * + * When the index is created, the buckets are sorted in their natural + * order, and the items within the buckets sorted by the date they are taken. + * + * The index enables the access of items and bucket labels as one unified list. + * For example, let's say we have the following data in the index: + * [Bucket A]: [photo 1], [photo 2] + * [Bucket B]: [photo 3] + * + * Then the items can be thought of as being organized as a 5 element list: + * [Bucket A], [photo 1], [photo 2], [Bucket B], [photo 3] + * + * The data can also be accessed in descending order, in which case the list + * would be a bit different from simply reversing the ascending list, since the + * bucket labels need to always be at the beginning: + * [Bucket B], [photo 3], [Bucket A], [photo 2], [photo 1] + * + * The index enables all the following operations in constant time, both for + * ascending and descending views of the data: + * - get/getAscending/getDescending: get an item at a specified list position + * - size: get the total number of items (bucket labels and MTP objects) + * - getFirstPositionForBucketNumber + * - getBucketNumberForPosition + * - isFirstInBucket + * + * See {@link MtpDeviceIndexRunnable} for implementation notes. + */ +@TargetApi(Build.VERSION_CODES.HONEYCOMB_MR1) +public class MtpDeviceIndex { + + /** + * Indexing progress listener. + */ + public interface ProgressListener { + /** + * A media item on the device was indexed. + * @param object The media item that was just indexed + * @param numVisited Number of items visited so far + */ + public void onObjectIndexed(IngestObjectInfo object, int numVisited); + + /** + * The metadata loaded from the device is being sorted. + */ + public void onSortingStarted(); + + /** + * The indexing is done and the index is ready to be used. + */ + public void onIndexingFinished(); + } + + /** + * Media sort orders. + */ + public enum SortOrder { + ASCENDING, DESCENDING + } + + /** Quicktime MOV container (not already defined in {@link MtpConstants}) **/ + public static final int FORMAT_MOV = 0x300D; + + public static final Set<Integer> SUPPORTED_IMAGE_FORMATS; + public static final Set<Integer> SUPPORTED_VIDEO_FORMATS; + + static { + Set<Integer> supportedImageFormats = new HashSet<Integer>(); + supportedImageFormats.add(MtpConstants.FORMAT_JFIF); + supportedImageFormats.add(MtpConstants.FORMAT_EXIF_JPEG); + supportedImageFormats.add(MtpConstants.FORMAT_PNG); + supportedImageFormats.add(MtpConstants.FORMAT_GIF); + supportedImageFormats.add(MtpConstants.FORMAT_BMP); + SUPPORTED_IMAGE_FORMATS = Collections.unmodifiableSet(supportedImageFormats); + + Set<Integer> supportedVideoFormats = new HashSet<Integer>(); + supportedVideoFormats.add(MtpConstants.FORMAT_3GP_CONTAINER); + supportedVideoFormats.add(MtpConstants.FORMAT_AVI); + supportedVideoFormats.add(MtpConstants.FORMAT_MP4_CONTAINER); + supportedVideoFormats.add(MtpConstants.FORMAT_MPEG); + // TODO(georgescu): add FORMAT_MOV once Android Media Scanner supports .mov files + SUPPORTED_VIDEO_FORMATS = Collections.unmodifiableSet(supportedVideoFormats); + } + + private MtpDevice mDevice; + private long mGeneration; + private ProgressListener mProgressListener; + private volatile MtpDeviceIndexRunnable.Results mResults; + private final MtpDeviceIndexRunnable.Factory mIndexRunnableFactory; + + private static final MtpDeviceIndex sInstance = new MtpDeviceIndex( + MtpDeviceIndexRunnable.getFactory()); + + public static MtpDeviceIndex getInstance() { + return sInstance; + } + + protected MtpDeviceIndex(MtpDeviceIndexRunnable.Factory indexRunnableFactory) { + mIndexRunnableFactory = indexRunnableFactory; + } + + public synchronized MtpDevice getDevice() { + return mDevice; + } + + public synchronized boolean isDeviceConnected() { + return (mDevice != null); + } + + /** + * @param format Media format from {@link MtpConstants} + * @return Whether the format is supported by this index. + */ + public boolean isFormatSupported(int format) { + return SUPPORTED_IMAGE_FORMATS.contains(format) + || SUPPORTED_VIDEO_FORMATS.contains(format); + } + + /** + * Sets the MtpDevice that should be indexed and initializes state, but does + * not kick off the actual indexing task, which is instead done by using + * {@link #getIndexRunnable()} + * + * @param device The MtpDevice that should be indexed + */ + public synchronized void setDevice(MtpDevice device) { + if (device == mDevice) { + return; + } + mDevice = device; + resetState(); + } + + /** + * Provides a Runnable for the indexing task (assuming the state has already + * been correctly initialized by calling {@link #setDevice(MtpDevice)}). + * + * @return Runnable for the main indexing task + */ + public synchronized Runnable getIndexRunnable() { + if (!isDeviceConnected() || mResults != null) { + return null; + } + return mIndexRunnableFactory.createMtpDeviceIndexRunnable(this); + } + + /** + * @return Whether the index is ready to be used. + */ + public synchronized boolean isIndexReady() { + return mResults != null; + } + + /** + * @param listener + * @return Current progress (useful for configuring initial UI state) + */ + public synchronized void setProgressListener(ProgressListener listener) { + mProgressListener = listener; + } + + /** + * Make the listener null if it matches the argument + * + * @param listener Listener to unset, if currently registered + */ + public synchronized void unsetProgressListener(ProgressListener listener) { + if (mProgressListener == listener) { + mProgressListener = null; + } + } + + /** + * @return The total number of elements in the index (labels and items) + */ + public int size() { + MtpDeviceIndexRunnable.Results results = mResults; + return results != null ? results.unifiedLookupIndex.length : 0; + } + + /** + * @param position Index of item to fetch, where 0 is the first item in the + * specified order + * @param order + * @return the bucket label or IngestObjectInfo at the specified position and + * order + */ + public Object get(int position, SortOrder order) { + MtpDeviceIndexRunnable.Results results = mResults; + if (results == null) { + return null; + } + if (order == SortOrder.ASCENDING) { + DateBucket bucket = results.buckets[results.unifiedLookupIndex[position]]; + if (bucket.unifiedStartIndex == position) { + return bucket.date; + } else { + return results.mtpObjects[bucket.itemsStartIndex + position - 1 + - bucket.unifiedStartIndex]; + } + } else { + int zeroIndex = results.unifiedLookupIndex.length - 1 - position; + DateBucket bucket = results.buckets[results.unifiedLookupIndex[zeroIndex]]; + if (bucket.unifiedEndIndex == zeroIndex) { + return bucket.date; + } else { + return results.mtpObjects[bucket.itemsStartIndex + zeroIndex + - bucket.unifiedStartIndex]; + } + } + } + + /** + * @param position Index of item to fetch from a view of the data that does not + * include labels and is in the specified order + * @return position-th item in specified order, when not including labels + */ + public IngestObjectInfo getWithoutLabels(int position, SortOrder order) { + MtpDeviceIndexRunnable.Results results = mResults; + if (results == null) { + return null; + } + if (order == SortOrder.ASCENDING) { + return results.mtpObjects[position]; + } else { + return results.mtpObjects[results.mtpObjects.length - 1 - position]; + } + } + + /** + * @param position Index of item to map from a view of the data that does not + * include labels and is in the specified order + * @param order + * @return position in a view of the data that does include labels, or -1 if the index isn't + * ready + */ + public int getPositionFromPositionWithoutLabels(int position, SortOrder order) { + /* Although this is O(log(number of buckets)), and thus should not be used + in hotspots, even if the attached device has items for every day for + a five-year timeframe, it would still only take 11 iterations at most, + so shouldn't be a huge issue. */ + MtpDeviceIndexRunnable.Results results = mResults; + if (results == null) { + return -1; + } + if (order == SortOrder.DESCENDING) { + position = results.mtpObjects.length - 1 - position; + } + int bucketNumber = 0; + int iMin = 0; + int iMax = results.buckets.length - 1; + while (iMax >= iMin) { + int iMid = (iMax + iMin) / 2; + if (results.buckets[iMid].itemsStartIndex + results.buckets[iMid].numItems + <= position) { + iMin = iMid + 1; + } else if (results.buckets[iMid].itemsStartIndex > position) { + iMax = iMid - 1; + } else { + bucketNumber = iMid; + break; + } + } + int mappedPos = results.buckets[bucketNumber].unifiedStartIndex + position + - results.buckets[bucketNumber].itemsStartIndex + 1; + if (order == SortOrder.DESCENDING) { + mappedPos = results.unifiedLookupIndex.length - mappedPos; + } + return mappedPos; + } + + /** + * @param position Index of item to map from a view of the data that + * includes labels and is in the specified order + * @param order + * @return position in a view of the data that does not include labels, or -1 if the index isn't + * ready + */ + public int getPositionWithoutLabelsFromPosition(int position, SortOrder order) { + MtpDeviceIndexRunnable.Results results = mResults; + if (results == null) { + return -1; + } + if (order == SortOrder.ASCENDING) { + DateBucket bucket = results.buckets[results.unifiedLookupIndex[position]]; + if (bucket.unifiedStartIndex == position) { + position++; + } + return bucket.itemsStartIndex + position - 1 - bucket.unifiedStartIndex; + } else { + int zeroIndex = results.unifiedLookupIndex.length - 1 - position; + DateBucket bucket = results.buckets[results.unifiedLookupIndex[zeroIndex]]; + if (bucket.unifiedEndIndex == zeroIndex) { + zeroIndex--; + } + return results.mtpObjects.length - 1 - bucket.itemsStartIndex + - zeroIndex + bucket.unifiedStartIndex; + } + } + + /** + * @return The number of media items in the index + */ + public int sizeWithoutLabels() { + MtpDeviceIndexRunnable.Results results = mResults; + return results != null ? results.mtpObjects.length : 0; + } + + /** + * @param bucketNumber Index of bucket in the specified order + * @param order + * @return position of bucket's first item in a view of the data that includes labels + */ + public int getFirstPositionForBucketNumber(int bucketNumber, SortOrder order) { + MtpDeviceIndexRunnable.Results results = mResults; + if (order == SortOrder.ASCENDING) { + return results.buckets[bucketNumber].unifiedStartIndex; + } else { + return results.unifiedLookupIndex.length + - results.buckets[results.buckets.length - 1 - bucketNumber].unifiedEndIndex + - 1; + } + } + + /** + * @param position Index of item in the view of the data that includes labels and is in + * the specified order + * @param order + * @return Index of the bucket that contains the specified item + */ + public int getBucketNumberForPosition(int position, SortOrder order) { + MtpDeviceIndexRunnable.Results results = mResults; + if (order == SortOrder.ASCENDING) { + return results.unifiedLookupIndex[position]; + } else { + return results.buckets.length - 1 + - results.unifiedLookupIndex[results.unifiedLookupIndex.length - 1 + - position]; + } + } + + /** + * @param position Index of item in the view of the data that includes labels and is in + * the specified order + * @param order + * @return Whether the specified item is the first item in its bucket + */ + public boolean isFirstInBucket(int position, SortOrder order) { + MtpDeviceIndexRunnable.Results results = mResults; + if (order == SortOrder.ASCENDING) { + return results.buckets[results.unifiedLookupIndex[position]].unifiedStartIndex + == position; + } else { + position = results.unifiedLookupIndex.length - 1 - position; + return results.buckets[results.unifiedLookupIndex[position]].unifiedEndIndex + == position; + } + } + + /** + * @param order + * @return Array of buckets in the specified order + */ + public DateBucket[] getBuckets(SortOrder order) { + MtpDeviceIndexRunnable.Results results = mResults; + if (results == null) { + return null; + } + return (order == SortOrder.ASCENDING) ? results.buckets : results.reversedBuckets; + } + + protected void resetState() { + mGeneration++; + mResults = null; + } + + /** + * @param device + * @param generation + * @return whether the index is at the given generation and the given device is connected + */ + protected boolean isAtGeneration(MtpDevice device, long generation) { + return (mGeneration == generation) && (mDevice == device); + } + + protected synchronized boolean setIndexingResults(MtpDevice device, long generation, + MtpDeviceIndexRunnable.Results results) { + if (!isAtGeneration(device, generation)) { + return false; + } + mResults = results; + onIndexFinish(true /*successful*/); + return true; + } + + protected synchronized void onIndexFinish(boolean successful) { + if (!successful) { + resetState(); + } + if (mProgressListener != null) { + mProgressListener.onIndexingFinished(); + } + } + + protected synchronized void onSorting() { + if (mProgressListener != null) { + mProgressListener.onSortingStarted(); + } + } + + protected synchronized void onObjectIndexed(IngestObjectInfo object, int numVisited) { + if (mProgressListener != null) { + mProgressListener.onObjectIndexed(object, numVisited); + } + } + + protected long getGeneration() { + return mGeneration; + } +} diff --git a/src/com/android/gallery3d/ingest/data/MtpDeviceIndexRunnable.java b/src/com/android/gallery3d/ingest/data/MtpDeviceIndexRunnable.java new file mode 100644 index 000000000..32275898e --- /dev/null +++ b/src/com/android/gallery3d/ingest/data/MtpDeviceIndexRunnable.java @@ -0,0 +1,186 @@ +package com.android.gallery3d.ingest.data; + +import android.annotation.TargetApi; +import android.mtp.MtpConstants; +import android.mtp.MtpDevice; +import android.mtp.MtpObjectInfo; +import android.os.Build; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.SortedMap; +import java.util.Stack; +import java.util.TreeMap; + +/** + * Runnable used by the {@link MtpDeviceIndex} to populate its index. + * + * Implementation note: this is the way the index supports a lot of its operations in + * constant time and respecting the need to have bucket names always come before items + * in that bucket when accessing the list sequentially, both in ascending and descending + * orders. + * + * Let's say the data we have in the index is the following: + * [Bucket A]: [photo 1], [photo 2] + * [Bucket B]: [photo 3] + * + * In this case, the lookup index array would be + * [0, 0, 0, 1, 1] + * + * Now, whether we access the list in ascending or descending order, we know which bucket + * to look in (0 corresponds to A and 1 to B), and can return the bucket label as the first + * item in a bucket as needed. The individual IndexBUckets have a startIndex and endIndex + * that correspond to indices in this lookup index array, allowing us to calculate the + * offset of the specific item we want from within a specific bucket. + */ +@TargetApi(Build.VERSION_CODES.HONEYCOMB_MR1) +public class MtpDeviceIndexRunnable implements Runnable { + + /** + * MtpDeviceIndexRunnable factory. + */ + public static class Factory { + public MtpDeviceIndexRunnable createMtpDeviceIndexRunnable(MtpDeviceIndex index) { + return new MtpDeviceIndexRunnable(index); + } + } + + static class Results { + final int[] unifiedLookupIndex; + final IngestObjectInfo[] mtpObjects; + final DateBucket[] buckets; + final DateBucket[] reversedBuckets; + + public Results( + int[] unifiedLookupIndex, IngestObjectInfo[] mtpObjects, DateBucket[] buckets) { + this.unifiedLookupIndex = unifiedLookupIndex; + this.mtpObjects = mtpObjects; + this.buckets = buckets; + this.reversedBuckets = new DateBucket[buckets.length]; + for (int i = 0; i < buckets.length; i++) { + this.reversedBuckets[i] = buckets[buckets.length - 1 - i]; + } + } + } + + private final MtpDevice mDevice; + protected final MtpDeviceIndex mIndex; + private final long mIndexGeneration; + + private static Factory sDefaultFactory = new Factory(); + + public static Factory getFactory() { + return sDefaultFactory; + } + + /** + * Exception thrown when a problem occurred during indexing. + */ + @SuppressWarnings("serial") + public class IndexingException extends RuntimeException {} + + MtpDeviceIndexRunnable(MtpDeviceIndex index) { + mIndex = index; + mDevice = index.getDevice(); + mIndexGeneration = index.getGeneration(); + } + + @Override + public void run() { + try { + indexDevice(); + } catch (IndexingException e) { + mIndex.onIndexFinish(false /*successful*/); + } + } + + private void indexDevice() throws IndexingException { + SortedMap<SimpleDate, List<IngestObjectInfo>> bucketsTemp = + new TreeMap<SimpleDate, List<IngestObjectInfo>>(); + int numObjects = addAllObjects(bucketsTemp); + mIndex.onSorting(); + int numBuckets = bucketsTemp.size(); + DateBucket[] buckets = new DateBucket[numBuckets]; + IngestObjectInfo[] mtpObjects = new IngestObjectInfo[numObjects]; + int[] unifiedLookupIndex = new int[numObjects + numBuckets]; + int currentUnifiedIndexEntry = 0; + int currentItemsEntry = 0; + int nextUnifiedEntry, unifiedStartIndex, numBucketObjects, unifiedEndIndex, itemsStartIndex; + + int i = 0; + for (Map.Entry<SimpleDate, List<IngestObjectInfo>> bucketTemp : bucketsTemp.entrySet()) { + List<IngestObjectInfo> objects = bucketTemp.getValue(); + Collections.sort(objects); + numBucketObjects = objects.size(); + + nextUnifiedEntry = currentUnifiedIndexEntry + numBucketObjects + 1; + Arrays.fill(unifiedLookupIndex, currentUnifiedIndexEntry, nextUnifiedEntry, i); + unifiedStartIndex = currentUnifiedIndexEntry; + unifiedEndIndex = nextUnifiedEntry - 1; + currentUnifiedIndexEntry = nextUnifiedEntry; + + itemsStartIndex = currentItemsEntry; + for (int j = 0; j < numBucketObjects; j++) { + mtpObjects[currentItemsEntry] = objects.get(j); + currentItemsEntry++; + } + buckets[i] = new DateBucket(bucketTemp.getKey(), unifiedStartIndex, unifiedEndIndex, + itemsStartIndex, numBucketObjects); + i++; + } + if (!mIndex.setIndexingResults(mDevice, mIndexGeneration, + new Results(unifiedLookupIndex, mtpObjects, buckets))) { + throw new IndexingException(); + } + } + + private SimpleDate mDateInstance = new SimpleDate(); + + protected void addObject(IngestObjectInfo objectInfo, + SortedMap<SimpleDate, List<IngestObjectInfo>> bucketsTemp, int numObjects) { + mDateInstance.setTimestamp(objectInfo.getDateCreated()); + List<IngestObjectInfo> bucket = bucketsTemp.get(mDateInstance); + if (bucket == null) { + bucket = new ArrayList<IngestObjectInfo>(); + bucketsTemp.put(mDateInstance, bucket); + mDateInstance = new SimpleDate(); // only create new date objects when they are used + } + bucket.add(objectInfo); + mIndex.onObjectIndexed(objectInfo, numObjects); + } + + protected int addAllObjects(SortedMap<SimpleDate, List<IngestObjectInfo>> bucketsTemp) + throws IndexingException { + int numObjects = 0; + for (int storageId : mDevice.getStorageIds()) { + if (!mIndex.isAtGeneration(mDevice, mIndexGeneration)) { + throw new IndexingException(); + } + Stack<Integer> pendingDirectories = new Stack<Integer>(); + pendingDirectories.add(0xFFFFFFFF); // start at the root of the device + while (!pendingDirectories.isEmpty()) { + if (!mIndex.isAtGeneration(mDevice, mIndexGeneration)) { + throw new IndexingException(); + } + int dirHandle = pendingDirectories.pop(); + for (int objectHandle : mDevice.getObjectHandles(storageId, 0, dirHandle)) { + MtpObjectInfo mtpObjectInfo = mDevice.getObjectInfo(objectHandle); + if (mtpObjectInfo == null) { + throw new IndexingException(); + } + int format = mtpObjectInfo.getFormat(); + if (format == MtpConstants.FORMAT_ASSOCIATION) { + pendingDirectories.add(objectHandle); + } else if (mIndex.isFormatSupported(format)) { + numObjects++; + addObject(new IngestObjectInfo(mtpObjectInfo), bucketsTemp, numObjects); + } + } + } + } + return numObjects; + } +} diff --git a/src/com/android/gallery3d/ingest/data/SimpleDate.java b/src/com/android/gallery3d/ingest/data/SimpleDate.java new file mode 100644 index 000000000..2476f80ed --- /dev/null +++ b/src/com/android/gallery3d/ingest/data/SimpleDate.java @@ -0,0 +1,127 @@ +/* + * Copyright (C) 2013 The Android Open Source Project + * + * 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.android.gallery3d.ingest.data; + +import android.annotation.TargetApi; +import android.os.Build; + +import java.text.DateFormat; +import java.util.Calendar; + +/** + * Represents a date (year, month, day) + */ +@TargetApi(Build.VERSION_CODES.HONEYCOMB_MR1) +public class SimpleDate implements Comparable<SimpleDate> { + public int month; // MM + public int day; // DD + public int year; // YYYY + private long timestamp; + private String mCachedStringRepresentation; + + public SimpleDate() { + } + + public SimpleDate(long timestamp) { + setTimestamp(timestamp); + } + + private static Calendar sCalendarInstance = Calendar.getInstance(); + + public void setTimestamp(long timestamp) { + synchronized (sCalendarInstance) { + // TODO(georgescu): find a more efficient way to convert a timestamp to a date? + sCalendarInstance.setTimeInMillis(timestamp); + this.day = sCalendarInstance.get(Calendar.DATE); + this.month = sCalendarInstance.get(Calendar.MONTH); + this.year = sCalendarInstance.get(Calendar.YEAR); + this.timestamp = timestamp; + mCachedStringRepresentation = + DateFormat.getDateInstance(DateFormat.SHORT).format(timestamp); + } + } + + @Override + public int hashCode() { + final int prime = 31; + int result = 1; + result = prime * result + day; + result = prime * result + month; + result = prime * result + year; + return result; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null) { + return false; + } + if (!(obj instanceof SimpleDate)) { + return false; + } + SimpleDate other = (SimpleDate) obj; + if (year != other.year) { + return false; + } + if (month != other.month) { + return false; + } + if (day != other.day) { + return false; + } + return true; + } + + @Override + public int compareTo(SimpleDate other) { + int yearDiff = this.year - other.getYear(); + if (yearDiff != 0) { + return yearDiff; + } else { + int monthDiff = this.month - other.getMonth(); + if (monthDiff != 0) { + return monthDiff; + } else { + return this.day - other.getDay(); + } + } + } + + public int getDay() { + return day; + } + + public int getMonth() { + return month; + } + + public int getYear() { + return year; + } + + @Override + public String toString() { + if (mCachedStringRepresentation == null) { + mCachedStringRepresentation = + DateFormat.getDateInstance(DateFormat.SHORT).format(timestamp); + } + return mCachedStringRepresentation; + } +} |