From 4e5d7d70dca78c0d948acb5db73ad6cfca0554d5 Mon Sep 17 00:00:00 2001 From: David Carver Date: Thu, 14 May 2015 16:40:20 -0400 Subject: [PATCH 1/2] [463143] Fork DDMSUILib since it is no longer maintained Also need to remove JFreeChart. Bug: https://bugs.eclipse.org/bugs/show_bug.cgi?id=463143 Signed-off-by: David Carver --- .../org.eclipse.andmore.ddmsuilib/.classpath | 8 + .../org.eclipse.andmore.ddmsuilib/.project | 34 + .../org.eclipse.core.resources.prefs | 2 + .../.settings/org.eclipse.jdt.core.prefs | 7 + .../.settings/org.eclipse.m2e.core.prefs | 4 + .../META-INF/MANIFEST.MF | 27 + .../build.properties | 5 + .../files/add.png | Bin 0 -> 146 bytes .../files/android.png | Bin 0 -> 3609 bytes .../files/backward.png | Bin 0 -> 136 bytes .../files/capture.png | Bin 0 -> 691 bytes .../files/clear.png | Bin 0 -> 217 bytes .../org.eclipse.andmore.ddmsuilib/files/d.png | Bin 0 -> 638 bytes .../files/debug-attach.png | Bin 0 -> 156 bytes .../files/debug-error.png | Bin 0 -> 222 bytes .../files/debug-wait.png | Bin 0 -> 156 bytes .../files/delete.png | Bin 0 -> 107 bytes .../files/device.png | Bin 0 -> 135 bytes .../files/diff.png | Bin 0 -> 213 bytes .../files/displayfilters.png | Bin 0 -> 242 bytes .../files/down.png | Bin 0 -> 141 bytes .../org.eclipse.andmore.ddmsuilib/files/e.png | Bin 0 -> 511 bytes .../files/edit.png | Bin 0 -> 223 bytes .../files/empty.png | Bin 0 -> 75 bytes .../files/emulator.png | Bin 0 -> 287 bytes .../files/file.png | Bin 0 -> 157 bytes .../files/folder.png | Bin 0 -> 123 bytes .../files/forward.png | Bin 0 -> 137 bytes .../files/gc.png | Bin 0 -> 165 bytes .../files/groupby.png | Bin 0 -> 413 bytes .../files/halt.png | Bin 0 -> 197 bytes .../files/heap.png | Bin 0 -> 222 bytes .../files/hprof.png | Bin 0 -> 317 bytes .../org.eclipse.andmore.ddmsuilib/files/i.png | Bin 0 -> 498 bytes .../files/importBug.png | Bin 0 -> 191 bytes .../files/load.png | Bin 0 -> 163 bytes .../files/pause.png | Bin 0 -> 98 bytes .../files/pause_logcat.png | Bin 0 -> 180 bytes .../files/play.png | Bin 0 -> 138 bytes .../files/pull.png | Bin 0 -> 329 bytes .../files/push.png | Bin 0 -> 228 bytes .../files/save.png | Bin 0 -> 240 bytes .../files/sort_down.png | Bin 0 -> 102 bytes .../files/sort_up.png | Bin 0 -> 105 bytes .../files/thread.png | Bin 0 -> 121 bytes .../files/tracing_start.png | Bin 0 -> 227 bytes .../files/tracing_stop.png | Bin 0 -> 217 bytes .../files/up.png | Bin 0 -> 134 bytes .../org.eclipse.andmore.ddmsuilib/files/v.png | Bin 0 -> 587 bytes .../org.eclipse.andmore.ddmsuilib/files/w.png | Bin 0 -> 681 bytes .../files/warning.png | Bin 0 -> 147 bytes .../files/zygote.png | Bin 0 -> 345 bytes .../lib/ddmlib.jar | Bin 0 -> 269790 bytes .../org.eclipse.andmore.ddmsuilib/pom.xml | 34 + .../java/com/android/ddmuilib/Addr2Line.java | 355 ++++ .../com/android/ddmuilib/AllocationPanel.java | 662 +++++++ .../android/ddmuilib/BackgroundThread.java | 50 + .../com/android/ddmuilib/BaseHeapPanel.java | 193 ++ .../android/ddmuilib/ClientDisplayPanel.java | 33 + .../android/ddmuilib/DdmUiPreferences.java | 79 + .../com/android/ddmuilib/DevicePanel.java | 830 +++++++++ .../ddmuilib/EmulatorControlPanel.java | 1463 +++++++++++++++ .../java/com/android/ddmuilib/HeapPanel.java | 1310 +++++++++++++ .../android/ddmuilib/ITableFocusListener.java | 38 + .../com/android/ddmuilib/ImageLoader.java | 206 +++ .../java/com/android/ddmuilib/InfoPanel.java | 199 ++ .../com/android/ddmuilib/NativeHeapPanel.java | 1648 +++++++++++++++++ .../main/java/com/android/ddmuilib/Panel.java | 49 + .../com/android/ddmuilib/PortFieldEditor.java | 73 + .../android/ddmuilib/ScreenShotDialog.java | 350 ++++ .../ddmuilib/SelectionDependentPanel.java | 78 + .../com/android/ddmuilib/StackTracePanel.java | 268 +++ .../android/ddmuilib/SyncProgressHelper.java | 100 + .../android/ddmuilib/SyncProgressMonitor.java | 60 + .../com/android/ddmuilib/SysinfoPanel.java | 595 ++++++ .../com/android/ddmuilib/TableHelper.java | 209 +++ .../java/com/android/ddmuilib/TablePanel.java | 132 ++ .../com/android/ddmuilib/ThreadPanel.java | 589 ++++++ .../ddmuilib/actions/ICommonAction.java | 42 + .../ddmuilib/actions/ToolItemAction.java | 71 + .../android/ddmuilib/annotation/UiThread.java | 31 + .../ddmuilib/annotation/WorkerThread.java | 31 + .../android/ddmuilib/console/DdmConsole.java | 91 + .../android/ddmuilib/console/IDdmConsole.java | 47 + .../explorer/DeviceContentProvider.java | 177 ++ .../ddmuilib/explorer/DeviceExplorer.java | 922 +++++++++ .../ddmuilib/explorer/FileLabelProvider.java | 160 ++ .../ddmuilib/handler/BaseFileHandler.java | 184 ++ .../handler/MethodProfilingHandler.java | 195 ++ .../ddmuilib/heap/NativeHeapDataImporter.java | 222 +++ .../ddmuilib/heap/NativeHeapDiffSnapshot.java | 65 + .../heap/NativeHeapLabelProvider.java | 112 ++ .../ddmuilib/heap/NativeHeapPanel.java | 1152 ++++++++++++ .../heap/NativeHeapProviderByAllocations.java | 90 + .../heap/NativeHeapProviderByLibrary.java | 92 + .../ddmuilib/heap/NativeHeapSnapshot.java | 133 ++ .../heap/NativeLibraryAllocationInfo.java | 135 ++ .../heap/NativeStackContentProvider.java | 56 + .../heap/NativeStackLabelProvider.java | 71 + .../heap/NativeSymbolResolverTask.java | 306 +++ .../ddmuilib/location/CoordinateControls.java | 249 +++ .../android/ddmuilib/location/GpxParser.java | 373 ++++ .../android/ddmuilib/location/KmlParser.java | 210 +++ .../ddmuilib/location/LocationPoint.java | 53 + .../location/TrackContentProvider.java | 48 + .../ddmuilib/location/TrackLabelProvider.java | 87 + .../android/ddmuilib/location/TrackPoint.java | 34 + .../android/ddmuilib/location/WayPoint.java | 42 + .../location/WayPointContentProvider.java | 46 + .../location/WayPointLabelProvider.java | 79 + .../ddmuilib/log/event/BugReportImporter.java | 96 + .../log/event/DisplayFilteredLog.java | 55 + .../ddmuilib/log/event/DisplayGraph.java | 422 +++++ .../ddmuilib/log/event/DisplayLog.java | 381 ++++ .../ddmuilib/log/event/DisplaySync.java | 304 +++ .../log/event/DisplaySyncHistogram.java | 181 ++ .../ddmuilib/log/event/DisplaySyncPerf.java | 227 +++ .../ddmuilib/log/event/EventDisplay.java | 975 ++++++++++ .../log/event/EventDisplayOptions.java | 961 ++++++++++ .../ddmuilib/log/event/EventLogImporter.java | 95 + .../ddmuilib/log/event/EventLogPanel.java | 935 ++++++++++ .../log/event/EventValueSelector.java | 630 +++++++ .../log/event/OccurrenceRenderer.java | 90 + .../ddmuilib/log/event/SyncCommon.java | 173 ++ .../ddmuilib/logcat/EditFilterDialog.java | 397 ++++ .../logcat/ILogCatMessageEventListener.java | 29 + .../ILogCatMessageSelectionListener.java | 24 + .../android/ddmuilib/logcat/LogCatFilter.java | 286 +++ .../logcat/LogCatFilterContentProvider.java | 48 + .../logcat/LogCatFilterLabelProvider.java | 52 + .../logcat/LogCatFilterSettingsDialog.java | 327 ++++ .../LogCatFilterSettingsSerializer.java | 206 +++ .../ddmuilib/logcat/LogCatMessage.java | 96 + .../logcat/LogCatMessageContentProvider.java | 43 + .../logcat/LogCatMessageLabelProvider.java | 144 ++ .../ddmuilib/logcat/LogCatMessageList.java | 115 ++ .../ddmuilib/logcat/LogCatMessageParser.java | 94 + .../android/ddmuilib/logcat/LogCatPanel.java | 1203 ++++++++++++ .../logcat/LogCatPidToNameMapper.java | 133 ++ .../ddmuilib/logcat/LogCatReceiver.java | 201 ++ .../logcat/LogCatReceiverFactory.java | 95 + .../logcat/LogCatStackTraceParser.java | 81 + .../ddmuilib/logcat/LogCatViewerFilter.java | 46 + .../android/ddmuilib/logcat/LogColors.java | 27 + .../android/ddmuilib/logcat/LogFilter.java | 556 ++++++ .../com/android/ddmuilib/logcat/LogPanel.java | 1617 ++++++++++++++++ .../android/ddmuilib/net/NetworkPanel.java | 1108 +++++++++++ .../org.eclipse.andmore/andmore.target | 1 + android-core/pom.xml | 1 + 149 files changed, 27751 insertions(+) create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/.classpath create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/.project create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/.settings/org.eclipse.core.resources.prefs create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/.settings/org.eclipse.jdt.core.prefs create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/.settings/org.eclipse.m2e.core.prefs create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/META-INF/MANIFEST.MF create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/build.properties create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/files/add.png create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/files/android.png create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/files/backward.png create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/files/capture.png create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/files/clear.png create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/files/d.png create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/files/debug-attach.png create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/files/debug-error.png create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/files/debug-wait.png create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/files/delete.png create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/files/device.png create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/files/diff.png create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/files/displayfilters.png create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/files/down.png create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/files/e.png create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/files/edit.png create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/files/empty.png create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/files/emulator.png create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/files/file.png create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/files/folder.png create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/files/forward.png create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/files/gc.png create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/files/groupby.png create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/files/halt.png create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/files/heap.png create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/files/hprof.png create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/files/i.png create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/files/importBug.png create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/files/load.png create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/files/pause.png create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/files/pause_logcat.png create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/files/play.png create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/files/pull.png create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/files/push.png create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/files/save.png create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/files/sort_down.png create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/files/sort_up.png create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/files/thread.png create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/files/tracing_start.png create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/files/tracing_stop.png create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/files/up.png create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/files/v.png create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/files/w.png create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/files/warning.png create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/files/zygote.png create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/lib/ddmlib.jar create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/pom.xml create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/Addr2Line.java create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/AllocationPanel.java create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/BackgroundThread.java create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/BaseHeapPanel.java create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/ClientDisplayPanel.java create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/DdmUiPreferences.java create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/DevicePanel.java create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/EmulatorControlPanel.java create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/HeapPanel.java create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/ITableFocusListener.java create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/ImageLoader.java create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/InfoPanel.java create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/NativeHeapPanel.java create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/Panel.java create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/PortFieldEditor.java create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/ScreenShotDialog.java create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/SelectionDependentPanel.java create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/StackTracePanel.java create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/SyncProgressHelper.java create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/SyncProgressMonitor.java create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/SysinfoPanel.java create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/TableHelper.java create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/TablePanel.java create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/ThreadPanel.java create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/actions/ICommonAction.java create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/actions/ToolItemAction.java create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/annotation/UiThread.java create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/annotation/WorkerThread.java create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/console/DdmConsole.java create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/console/IDdmConsole.java create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/explorer/DeviceContentProvider.java create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/explorer/DeviceExplorer.java create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/explorer/FileLabelProvider.java create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/handler/BaseFileHandler.java create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/handler/MethodProfilingHandler.java create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/heap/NativeHeapDataImporter.java create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/heap/NativeHeapDiffSnapshot.java create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/heap/NativeHeapLabelProvider.java create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/heap/NativeHeapPanel.java create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/heap/NativeHeapProviderByAllocations.java create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/heap/NativeHeapProviderByLibrary.java create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/heap/NativeHeapSnapshot.java create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/heap/NativeLibraryAllocationInfo.java create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/heap/NativeStackContentProvider.java create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/heap/NativeStackLabelProvider.java create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/heap/NativeSymbolResolverTask.java create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/location/CoordinateControls.java create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/location/GpxParser.java create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/location/KmlParser.java create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/location/LocationPoint.java create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/location/TrackContentProvider.java create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/location/TrackLabelProvider.java create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/location/TrackPoint.java create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/location/WayPoint.java create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/location/WayPointContentProvider.java create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/location/WayPointLabelProvider.java create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/log/event/BugReportImporter.java create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/log/event/DisplayFilteredLog.java create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/log/event/DisplayGraph.java create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/log/event/DisplayLog.java create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/log/event/DisplaySync.java create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/log/event/DisplaySyncHistogram.java create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/log/event/DisplaySyncPerf.java create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/log/event/EventDisplay.java create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/log/event/EventDisplayOptions.java create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/log/event/EventLogImporter.java create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/log/event/EventLogPanel.java create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/log/event/EventValueSelector.java create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/log/event/OccurrenceRenderer.java create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/log/event/SyncCommon.java create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/logcat/EditFilterDialog.java create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/logcat/ILogCatMessageEventListener.java create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/logcat/ILogCatMessageSelectionListener.java create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/logcat/LogCatFilter.java create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/logcat/LogCatFilterContentProvider.java create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/logcat/LogCatFilterLabelProvider.java create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/logcat/LogCatFilterSettingsDialog.java create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/logcat/LogCatFilterSettingsSerializer.java create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/logcat/LogCatMessage.java create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/logcat/LogCatMessageContentProvider.java create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/logcat/LogCatMessageLabelProvider.java create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/logcat/LogCatMessageList.java create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/logcat/LogCatMessageParser.java create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/logcat/LogCatPanel.java create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/logcat/LogCatPidToNameMapper.java create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/logcat/LogCatReceiver.java create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/logcat/LogCatReceiverFactory.java create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/logcat/LogCatStackTraceParser.java create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/logcat/LogCatViewerFilter.java create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/logcat/LogColors.java create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/logcat/LogFilter.java create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/logcat/LogPanel.java create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/net/NetworkPanel.java diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/.classpath b/android-core/plugins/org.eclipse.andmore.ddmsuilib/.classpath new file mode 100644 index 00000000..7e59275c --- /dev/null +++ b/android-core/plugins/org.eclipse.andmore.ddmsuilib/.classpath @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/.project b/android-core/plugins/org.eclipse.andmore.ddmsuilib/.project new file mode 100644 index 00000000..2571ad4e --- /dev/null +++ b/android-core/plugins/org.eclipse.andmore.ddmsuilib/.project @@ -0,0 +1,34 @@ + + + org.eclipse.andmore.ddmsuilib + + + + + + org.eclipse.jdt.core.javabuilder + + + + + org.eclipse.pde.ManifestBuilder + + + + + org.eclipse.pde.SchemaBuilder + + + + + org.eclipse.m2e.core.maven2Builder + + + + + + org.eclipse.m2e.core.maven2Nature + org.eclipse.pde.PluginNature + org.eclipse.jdt.core.javanature + + diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/.settings/org.eclipse.core.resources.prefs b/android-core/plugins/org.eclipse.andmore.ddmsuilib/.settings/org.eclipse.core.resources.prefs new file mode 100644 index 00000000..99f26c02 --- /dev/null +++ b/android-core/plugins/org.eclipse.andmore.ddmsuilib/.settings/org.eclipse.core.resources.prefs @@ -0,0 +1,2 @@ +eclipse.preferences.version=1 +encoding/=UTF-8 diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/.settings/org.eclipse.jdt.core.prefs b/android-core/plugins/org.eclipse.andmore.ddmsuilib/.settings/org.eclipse.jdt.core.prefs new file mode 100644 index 00000000..c537b630 --- /dev/null +++ b/android-core/plugins/org.eclipse.andmore.ddmsuilib/.settings/org.eclipse.jdt.core.prefs @@ -0,0 +1,7 @@ +eclipse.preferences.version=1 +org.eclipse.jdt.core.compiler.codegen.inlineJsrBytecode=enabled +org.eclipse.jdt.core.compiler.codegen.targetPlatform=1.6 +org.eclipse.jdt.core.compiler.compliance=1.6 +org.eclipse.jdt.core.compiler.problem.assertIdentifier=error +org.eclipse.jdt.core.compiler.problem.enumIdentifier=error +org.eclipse.jdt.core.compiler.source=1.6 diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/.settings/org.eclipse.m2e.core.prefs b/android-core/plugins/org.eclipse.andmore.ddmsuilib/.settings/org.eclipse.m2e.core.prefs new file mode 100644 index 00000000..f897a7f1 --- /dev/null +++ b/android-core/plugins/org.eclipse.andmore.ddmsuilib/.settings/org.eclipse.m2e.core.prefs @@ -0,0 +1,4 @@ +activeProfiles= +eclipse.preferences.version=1 +resolveWorkspaceProjects=true +version=1 diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/META-INF/MANIFEST.MF b/android-core/plugins/org.eclipse.andmore.ddmsuilib/META-INF/MANIFEST.MF new file mode 100644 index 00000000..4aba9679 --- /dev/null +++ b/android-core/plugins/org.eclipse.andmore.ddmsuilib/META-INF/MANIFEST.MF @@ -0,0 +1,27 @@ +Manifest-Version: 1.0 +Bundle-ManifestVersion: 2 +Bundle-Name: Ddmsuilib +Bundle-SymbolicName: org.eclipse.andmore.ddmsuilib +Bundle-Version: 1.0.0.qualifier +Bundle-RequiredExecutionEnvironment: JavaSE-1.6 +Require-Bundle: org.eclipse.ui, + org.eclipse.core.runtime, + org.swtchart;bundle-version="0.7.0" +Bundle-ClassPath: lib/ddmlib.jar, + . +Export-Package: com.android.ddmlib, + com.android.ddmlib.log, + com.android.ddmlib.logcat, + com.android.ddmlib.testrunner, + com.android.ddmlib.utils, + com.android.ddmuilib, + com.android.ddmuilib.actions, + com.android.ddmuilib.annotation, + com.android.ddmuilib.console, + com.android.ddmuilib.explorer, + com.android.ddmuilib.handler, + com.android.ddmuilib.heap, + com.android.ddmuilib.location, + com.android.ddmuilib.log.event, + com.android.ddmuilib.logcat, + com.android.ddmuilib.net diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/build.properties b/android-core/plugins/org.eclipse.andmore.ddmsuilib/build.properties new file mode 100644 index 00000000..f0ce74d6 --- /dev/null +++ b/android-core/plugins/org.eclipse.andmore.ddmsuilib/build.properties @@ -0,0 +1,5 @@ +source.. = src/main/java/ +output.. = bin/ +bin.includes = META-INF/,\ + .,\ + lib/ddmlib.jar diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/files/add.png b/android-core/plugins/org.eclipse.andmore.ddmsuilib/files/add.png new file mode 100644 index 0000000000000000000000000000000000000000..eefc2ca300a00e1d80a61c8ddf21416d8e4215db GIT binary patch literal 146 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 literal 0 HcmV?d00001 diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/files/backward.png b/android-core/plugins/org.eclipse.andmore.ddmsuilib/files/backward.png new file mode 100644 index 0000000000000000000000000000000000000000..90a97137a2f418e0d926ce3b3c9f860b472174d8 GIT binary patch literal 136 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`{+=$5Ar`&K2@(n)+oS&zBn8V9q_lngiX2Azf+24zdO2bWxW$|X8Ciq3a*k!(1xFp1|t inm}Y?+gXKE3=F?d^GL9{&AtINn!(f6&t;ucLK6TNjVDF` literal 0 HcmV?d00001 diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/files/capture.png b/android-core/plugins/org.eclipse.andmore.ddmsuilib/files/capture.png new file mode 100644 index 0000000000000000000000000000000000000000..da5c10be437316a8c2bd4c099fe7ff82e821f497 GIT binary patch literal 691 zcmV;k0!;mhP)njzcdaRU4tfb%H7b4C zgN5~!7KDo=vZo$mMG+Jgh%I{&3Ry_(L84$WGLi^<(9I-jY|`8xCv*F@UBBBscWz_} z(t&f&x#yhU@7#O8^C?|tE?^_^4jtgAu>s+BprHqdJ_dGwkbA>MV3}V9JFfQuEDC9f zOpvTg;BcPc0{Ev&kpK35D9i`4l0+0KbcGVrd2+cHJGK=S0(ZI&L0O4`vxorvl*(h` z*g^~~S?E*s(#NhIC^xR&;h|6%iU^e$twMx3?sSxWv38%`2}g$yV-KD{&F8`D>fpR` z5&qHB01>3}ysCpweSADPQ&nB(oaH7si}&)Q$ND+@x|y^2PFYWJLw1?TPT_DAqrM^R>buGv>40hjw(?JKyWhc(%oH&BC4|gUwt8eD3J8K< zB@F?;4c!Av@V=Obp|%a)iI?D;jBuPB24U5BT0<(Gk)I_pnEF@DhPJTkfDm9*=2sCB@Vbv|_3etjr>l|89kyoIHq<5gJ zkPxA%#VY+=5wbof8WC2p+k3gSwF$aN(^v-I&mj8pO(JM55-q$s_v&h z&p{b{9^{^TBXW=`8JgkMk*b8&wb$IP@+kkEwL8!ATb_|M;hX&~1je5I!z=%VHI@Il Z`3*g%EuWWcPjUbN002ovPDHLkV1fkdG=u;E literal 0 HcmV?d00001 diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/files/clear.png b/android-core/plugins/org.eclipse.andmore.ddmsuilib/files/clear.png new file mode 100644 index 0000000000000000000000000000000000000000..0009cf662bc7ef4491e64a5b4298dd95df28f82c GIT binary patch literal 217 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`3p`yMLo9lyPK@SbR^)Ki_bhL^ zJ!5{}-rEgZ@BFiv7FaXo2#-a9joU$ey+7je3>B@t+jl#MUd}7euY4=XAnrLMl1pJ{ z=%Sf|4$-xD-!3#~XgHXVR+6$|qm}WENS=luFX>&MMKdxBivF*3Y{}64z@W^qa%W3i zntB#PRqI{F^FRNHGE6whHDRk`n$pA13}@Ol%THoRI~*APtIFKo;ny#2>uI%~zpma7 Q1iF>M)78&qol`;+02!536aWAK literal 0 HcmV?d00001 diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/files/d.png b/android-core/plugins/org.eclipse.andmore.ddmsuilib/files/d.png new file mode 100644 index 0000000000000000000000000000000000000000..d45506ee79252ad2c51ea6cd15aa8ee5538638e4 GIT binary patch literal 638 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4(FKU~=_zaSW-Lll15Re|u)Dgp`yc zA}I+8KY%>tT@pZL|Nkd78UFk8^RSMx@@I)ruyl-`}8o zwsQA6=d>=4rBj|WgLKE7GvsA3;5p*ZULo(oz&dMlXsEls`sa*QYRX4a_I*tb%`4yD zzzy*ctAwIK<|OsQK#%ZDoR}>E0!2kdU>f3H03goGJZrCM=k|Nr}&{^8@yqX&RsLvgwe z^QNUxH9tY&ylqMKg9FTMZEegv?R>TgH}?M*V`gUNZ=O5X21qj>o^M|-vCMDoDTB&S zPh_APLD6ON7HIJ7hbJbg^RV;TI2iV{wstODc8u@b^a&FZDn36uJ73jHyubc`-EUwd8bjr!8H`(I&iwiR$;ruvCh6(v|JzUfZ_E4#v<4Ws zKz}+JKCoAL?%TGLP1b*&jpc7(w4FKe3KFNx42v5|I{K9?W`hj#boFyt=akR{0PtNH AMF0Q* literal 0 HcmV?d00001 diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/files/debug-attach.png b/android-core/plugins/org.eclipse.andmore.ddmsuilib/files/debug-attach.png new file mode 100644 index 0000000000000000000000000000000000000000..9b8a11c40aa561ad975bc58e8f5d79c3868853a7 GIT binary patch literal 156 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`$(}BbAr`$`gWUNT6gZq8|KHyu zUMMQE_}dXL&E^-v6_3M@-1a-T^3WP#R*jk7Q+oFtI@6V!xgsv8>Di%%HaVS-vmD$a zPQGKD?z#7|!tedfcQq!=U%EZ`kv!YdyqlGOjv6nmOVOAi%`0pf5eKx6!PC{xWt~$( F695LOJC6VW literal 0 HcmV?d00001 diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/files/debug-error.png b/android-core/plugins/org.eclipse.andmore.ddmsuilib/files/debug-error.png new file mode 100644 index 0000000000000000000000000000000000000000..f22da1fffa8e6c9ab032bcc03e65c59a25429555 GIT binary patch literal 222 zcmV<403rX0P)t<#W!~WT?oM#%&h)=?Fur695x=omFdS9Rv`=v5rssjmcM7gMUoaAZPq# z5|uZ2-vnS5D@_Ea{4};BG$z}Yj&$k>8FIz@A|Kcj8*ll116s%6>FVdQ&MBb@ E0K+yna{vGU literal 0 HcmV?d00001 diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/files/delete.png b/android-core/plugins/org.eclipse.andmore.ddmsuilib/files/delete.png new file mode 100644 index 0000000000000000000000000000000000000000..db5fab8e41d3845ef8beb199500042c3f8494387 GIT binary patch literal 107 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`MxHK?Ar`&K2@zopr E0D=D>LI3~& literal 0 HcmV?d00001 diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/files/device.png b/android-core/plugins/org.eclipse.andmore.ddmsuilib/files/device.png new file mode 100644 index 0000000000000000000000000000000000000000..7dbbbb6a4b6d8e6d3d862fc36531df6b469133b9 GIT binary patch literal 135 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`ex5FlAr`&KDF^ueJr+n$0D>bY z6`hRR?8I_5S$~r~wDR)dlYFh`U0QN04z6Gf>2Q0a_oLq+?fNy@7fvMwjAz1Pz8DlU gnlW=oHZ5XjXpvSd|9FS57icnrr>mdKI;Vst0Q2K6DgXcg literal 0 HcmV?d00001 diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/files/diff.png b/android-core/plugins/org.eclipse.andmore.ddmsuilib/files/diff.png new file mode 100644 index 0000000000000000000000000000000000000000..bdd9e5c1f3948e995d9e168be8a7f4884d9f31a8 GIT binary patch literal 213 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`EX7WqAsj$Z!;#Vf2?p zUk71ECym(^Ktah8*NBqf{Irtt#G+J&^73-M%)IR4&* z_GTS{lZ-ki=6I>9F7nCVU*7Mx(Sb+eM+5`IiFtCC+otqQ1e(s^>FVdQ&MBb@09c|x AiU0rr literal 0 HcmV?d00001 diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/files/displayfilters.png b/android-core/plugins/org.eclipse.andmore.ddmsuilib/files/displayfilters.png new file mode 100644 index 0000000000000000000000000000000000000000..d110c2cfb3afe6247caca41c445791da0916bd4d GIT binary patch literal 242 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`EX7WqAsj$Z!;#Vf2?p zUk71ECym(^Ktah8*NBqf{Irtt#G+J&^73-M%)IR41WO<`e#Cm8MF*NS2g8Wf4$?%Y*WxI|AEzEcJqyt1#_!Io!fKx eT_e}mKjw?Oq^#d?VRi)2Y6eeNKbLh*2~7YJ*H0?| literal 0 HcmV?d00001 diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/files/down.png b/android-core/plugins/org.eclipse.andmore.ddmsuilib/files/down.png new file mode 100644 index 0000000000000000000000000000000000000000..f9426cbaab9a4fb6d23f0a3929377ec81e260bf8 GIT binary patch literal 141 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`A)YRdAr`&K2@)9xI=r1+U7y+; z{XaL?I^wF)L*u_d22c6BI|pAg?O_vOZfj)JIU)09!g1AhZMTH34@ouC1WZ~Mq}8+u ntd*RtsFLkw){=9!gqgu;pTXXo`MakB&1dj*^>bP0l+XkK2P-hw literal 0 HcmV?d00001 diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/files/e.png b/android-core/plugins/org.eclipse.andmore.ddmsuilib/files/e.png new file mode 100644 index 0000000000000000000000000000000000000000..dee7c97f87bd059e55b6667ad5deefd5ba2a7bc9 GIT binary patch literal 511 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4(FKU_9pO;uumfC+W}s|MtvQ2`MQ@ zL{btGegJvOyCi_h{{K&EGW_@F=V2XX<B-6eAkq6t8U#>dC|Z(9CC2N7rn>YVA zHZb_Z4p9LP4`pTLEQ<$R%xr9IZEb9A_Vxb^Hf-8t?(m zDx?{VTV~Gu`Txnu$%VgW0$JuhpMlbn5NRjF2lgt@ecN`j$@&BB{|yYcGbdg_Jk8AD W-^%N+vot3gq}J2b&t;ucLK6V>uHST5WJ6AArqvrOC+lFDWbF~vI0ARzyfB-3KT9ArAVI&DJ3*aknG?T5e6Ge$l@g1 z|F8S^z5FJOQ1Aok4S-^dd1ZH92LKdIgP&bHBgUaH{Odczw&&V63Vd&7a+(S#HFics zh(iI{kDLG&&gYA^?Y5LsAR;3+D;PI+Xg((?cS002ovPDHLkV1gh}apwR4 literal 0 HcmV?d00001 diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/files/file.png b/android-core/plugins/org.eclipse.andmore.ddmsuilib/files/file.png new file mode 100644 index 0000000000000000000000000000000000000000..043a81436d2df71bda1d1d73aa1a8f5e0b9219bf GIT binary patch literal 157 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`DV{ElAr`%FCm-Z$FyL|iedPcD zrxmv@Y_VDu&TQSYO@CpIpnU7yt8dOTXc{+65OJ7w(@T2B@fVs;;}}gxrIs@9Nc`fKkno@%4l%eFh`vwVO_G4r?{9w)`0{A236Id1?3Ek3_rMd V#1?+MuoY+;gQu&X%Q~loCIAZmB?SNg literal 0 HcmV?d00001 diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/files/forward.png b/android-core/plugins/org.eclipse.andmore.ddmsuilib/files/forward.png new file mode 100644 index 0000000000000000000000000000000000000000..a97a605627e298cddc78e2300e13284ada6330b4 GIT binary patch literal 137 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`0iG_7Ar`%BFCXM$P~cz==>7M9 z`fIfyp1WDkzbOT6?vM03$@t*8qgaNsXn<2JgOg6dSGK@You@+-7BPKnSt%CDP?yX) krMacyo5pjG(_iNBR`24Fv-#?14m6v=)78&qol`;+018wtxBvhE literal 0 HcmV?d00001 diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/files/gc.png b/android-core/plugins/org.eclipse.andmore.ddmsuilib/files/gc.png new file mode 100644 index 0000000000000000000000000000000000000000..51948064fa9ce4f1f45be46fa76bdc94cae2d3da GIT binary patch literal 165 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`Ii4<#Ar`&K2@3=ageLs=_t4d2 z{`Xkm_=UqwzMud9|6g19DlO$te-4M4_=C-|&hlpB26HS5nU?!;b90N>3G21q-j;jV zYcca2HUVxo-UG=E)2EBa+3`H^Id$U_lL_yF7YC=$+_2QcX=Z;31A_~zM3>}$#_2$N O89ZJ6T-G@yGywqK@;JKy literal 0 HcmV?d00001 diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/files/groupby.png b/android-core/plugins/org.eclipse.andmore.ddmsuilib/files/groupby.png new file mode 100644 index 0000000000000000000000000000000000000000..250b98276257e7c1564982c7672618b1433a4ecb GIT binary patch literal 413 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`EX7WqAsj$Z!;#Vf2?p zUk71ECym(^Ktah8*NBqf{Irtt#G+J&^73-M%)IR4v*T<*4r;2-x!*$HnLE-<{fsm{>Tk+X+k(%j<@x6btY zwJ=HcgYpE6lR=Yqac$)LQgVs^YwY!K+1Aw`YX2=|JadP&^woj)inihZfwzHoN2 z+_pB4x=?|W%$bvBx3kUwTDAGdw35BqcCmpI!l&|j#@?u^sFZS9%OJ*-;P&sTL*yyr z@>7o|b(Lyb7Nk5<@n$r!%VuC>c=5Y8*KV5l1-Jc@yKCRfcx=2G7?cd2u6{1-oD!M< D2WYBY literal 0 HcmV?d00001 diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/files/halt.png b/android-core/plugins/org.eclipse.andmore.ddmsuilib/files/halt.png new file mode 100644 index 0000000000000000000000000000000000000000..10e3720ae78844df90ffc6e4ff194630d41afd1c GIT binary patch literal 197 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`J)SO(Ar`$$CkOH!HsEmne(mmR z^XF-kzi-^4dbsnJsHVI_j`HLW28y2R_yV%TV&3PTb#l5tzkyvv=Bsdm^P$ua{G9cT z!Y8%WRTr#(zNgcUJ7Kn(zv;{k>K|TzpB&i~=+luuyQ03=z3#7~XH*$KgWt>>2Lv}V w3wP*jySeQcf2`~^gAcZMHg!f6u3}n9jh-} z+yC$L4UK>zihgq#d4S;14buh8Z{idfADrU;Upwtb0!Low&8CIzkIuLlGBEs+FlT+e S?v@eI!3>_RelF{r5}E)#rCUb; literal 0 HcmV?d00001 diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/files/hprof.png b/android-core/plugins/org.eclipse.andmore.ddmsuilib/files/hprof.png new file mode 100644 index 0000000000000000000000000000000000000000..123d062071a77d4eb7d4884d7e4f49dc05d86f4e GIT binary patch literal 317 zcmV-D0mA-?P)j@Y+K{ps+VFrnIL1JS84nY^hHG*xYgp`|Os6tFODGeh4rr3rNvXtX zOb~^KrrB^d+SmUx@R@5hHSyDvlTfuy%bD5O+Wzma|L@lM`tSGe|JUD-|6l+A?_Z5a zK=6O&%$fE7|NWgU2{xr^c{!WQt3$K7jvhVw-`K$5&;9-N`~Lz14kQh9PLni)aSPCY z|DT+kY-p06p8mi6)c>~3f1sEED{wM=V6XDrw{0hztp7Y4%iqADI&gxrIs@9Nc`fJIh3SniH+f8qT`v+?cv3U7sd<^i z-?-3}$)Gt)QbIx^kVhfZcuqnC8_&vFff77FE;AV^%&=;gU|`T~RH}A1Uj($2fx*+& K&t;ucLK6V8cr#i6 literal 0 HcmV?d00001 diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/files/pause.png b/android-core/plugins/org.eclipse.andmore.ddmsuilib/files/pause.png new file mode 100644 index 0000000000000000000000000000000000000000..19d286d3f6f28908d2c626a1ca6453f4d80e60bc GIT binary patch literal 98 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`nw~C>Ar`&K2@)9xI?nt&f8c|> uXVb(97R*2(yi`xZG~r6a!Ndaxm>HNput2?p zUk71ECym(^Ktah8*NBqf{Irtt#G+J&^73-M%)IR43+j6DNMO7b$i)TIcYScT4%f6^zQ47>+HRKa*j$7q_xl SoSHCDGlQqApUXO@geCwFsWtHc literal 0 HcmV?d00001 diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/files/play.png b/android-core/plugins/org.eclipse.andmore.ddmsuilib/files/play.png new file mode 100644 index 0000000000000000000000000000000000000000..d54f013f0948598fd98a1d0c622a213c779df052 GIT binary patch literal 138 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`fu1goAr`%BFCXM$P~cz==>7M9 z`r8|PO1HC)C+I8n{+QNPly#Gd^SPLUgRz5yjDdsH1&40E#0N(F4HXPFiMw5SCH57v lbvV9aOq`N`CbjAu7xV6oo&BC~Mz@xf4>WEEY@CO7J9=O4W^_15W}HKMTqdF)(#PN~TOaLs&t@9f(~g zaY{FiWa(J<-gD2DuSJ^W1-C+)<%M5z1u&g2Ta$+`eIU?Rp@|QuQ1(sY;Q7gLbv^;$ zbuM?Em&9wOMSL8XEmoDzY7!t`k4|S!0qsJF6KY~?lf;z~73>eK4s>UeZE+3JJ>GdP zfqL;@Xb8*}E2i^x>&y)o)Ly|x5-*JtmtdOh56%%ZLK#&GLm3guh|4|%?q_@(ec=7> z3GbsFqSro7!+~84F#a6#Afx{a)X^!=X1jC7oqJ&8`$}9;ek6^}_RH|I74i@~_U>tiaY`p$y+>T>J1R~fZM zPmEuqeXQWEz$Ryfi{%qjnpEoRx>6ccg!V=%TzuU0S1pa(v~%eZSxtt5?<^vw>!OyV zebau)HBD&2-}&<2R(Jhbk+CoPw#&3ku?3x3`l5`X=eOoslyaT^a;7jWxxLAG_40zQ cB?oF4isuCN)l5H833N7tr>mdKI;Vst0Jr&DJpcdz literal 0 HcmV?d00001 diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/files/save.png b/android-core/plugins/org.eclipse.andmore.ddmsuilib/files/save.png new file mode 100644 index 0000000000000000000000000000000000000000..040ebda68405a6161c33e216fa3c5c44a8e4d3af GIT binary patch literal 240 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`+dW+zLo9le6BY;A`P>~F`wFzvP0HD1%J5kN06c)I$ztaD0e0suw3U;+RD literal 0 HcmV?d00001 diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/files/sort_down.png b/android-core/plugins/org.eclipse.andmore.ddmsuilib/files/sort_down.png new file mode 100644 index 0000000000000000000000000000000000000000..2d4ccc1add771d4d8f9d941013bcc6c5746b3207 GIT binary patch literal 102 zcmeAS@N?(olHy`uVBq!ia0vp^AT}2V8<6ZZI=>f4>3X_2hDb;zCrB6^;Mn@po>AQ3 zkVw;&he14H&1(c^CzVu+bUUg&Hj;H!oNK{w*Ois2_5PXVK#dHZu6{1-oD!Mf48F;!lhDb;zCrE@mu)koc(8DAV zSTTd^!KVVY0OmH$0Otc)30#GmO_3bI?2j)`W@89@%*rG(>wOkbFN3G6pUXO@geCy* CXdEH{ literal 0 HcmV?d00001 diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/files/thread.png b/android-core/plugins/org.eclipse.andmore.ddmsuilib/files/thread.png new file mode 100644 index 0000000000000000000000000000000000000000..ac839e89dc739ec54cae63c0a39492df66c7e578 GIT binary patch literal 121 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`4xTQKAr`&K2@4p1_&4;&fBp{y zlan}tSjhcrzToL=>67sG1BDH+RVIl5H03eJ9#k+4Wh?TKhkqu~)IVFu$i$8&Od S0p>un7(8A5T-G@yGywpH11Hx2 literal 0 HcmV?d00001 diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/files/tracing_start.png b/android-core/plugins/org.eclipse.andmore.ddmsuilib/files/tracing_start.png new file mode 100644 index 0000000000000000000000000000000000000000..88771cc6b44ea4332fa572bd746ed587fb7bff58 GIT binary patch literal 227 zcmV<90382`P)PuAe#;3vzSgrM6KaL zbU;Oh*iOt$a3>9JR{=i>xu8Y}V4r@>#%hdSSI%c1;}e;7zmJdpxU$xSQ1o~14Cr#J z*VJx9Hj_Y|?z-UwMAOX#$$g=KhnUO@8u2wD7@`DduDp~hSn~vaOO&Yj8~6EV5*;Fy d8PWU?GY@6NXq!To*5v>I002ovPDHLkV1h|hUey2q literal 0 HcmV?d00001 diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/files/tracing_stop.png b/android-core/plugins/org.eclipse.andmore.ddmsuilib/files/tracing_stop.png new file mode 100644 index 0000000000000000000000000000000000000000..71bd215f4191f4abcfd64adda7dfe0f96d8bfa00 GIT binary patch literal 217 zcmV;~04D#5P)NklnqB zAV)nYxl@P!00A`f?c`uYmO>D~x_pV+=KYm=h5a*rp>kQIDfv7_W+n<5>vJrIDeFZ5 zGsPR*e)B}8p-I7RA``_It5Rdj8=#iH-eAh^Mcv5BpSTz7lhE=jGc$DJKg>JnHm2*8>e^@O1TaS?83{1OSo0E(rht literal 0 HcmV?d00001 diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/files/v.png b/android-core/plugins/org.eclipse.andmore.ddmsuilib/files/v.png new file mode 100644 index 0000000000000000000000000000000000000000..804405150e2ef415041e38ae244254a505e512c5 GIT binary patch literal 587 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4(FKU=s3laSW-Lll15Re|u)Dgp`yc zA}I+8KY%>tT@pZL|Nkd78UFk8^RSMx@@I) zYcF*%7e3~HZ`#>#J0w^80W;Xe2|ypj$H(_yR&z>%=u0`XkdeWF=di)^#y|emZtFi+ zxU(K{y?R&FpmUY6a>8x>3Wy6@*xAye8jcGYNyqX+M3R7poj7^&pn-$0Z*Ll@Nk%O*i3&ffVTjg9yB*Z&t67yp0$z=03-z+n6avIpX(CTRxa7GSvje{yoNp~>Mt zXU?4YBfab=Pyr7_fs^3_dzI(DZhST=HkF^AyaAeJth|~XY&bP0l+XkKW$_aL literal 0 HcmV?d00001 diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/files/w.png b/android-core/plugins/org.eclipse.andmore.ddmsuilib/files/w.png new file mode 100644 index 0000000000000000000000000000000000000000..129d0f9c22d49f3602e9119c0bc8227e1cea6fd4 GIT binary patch literal 681 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4(FKU@Gu*aSW-Lll15Re|u)Dgp`yc zA}I+8KY%>tT@pZL|Nkd78UFk8^RSMx@@I)^KFmIQAYlqqVVs~cGqe1amAoE$`3Wfr57HA(?3``O3eo0Z z^!ES%sex1{ibLW2k-#_nR{dR2^d5EeeRte@a8XgA>4DQ@uNc5I) zcwsqBpo;lOsp@WpE~v|r5)u+JGBO%G{sBWEAq|LAQW9p!a|oWc;9t1EzDeRwKV#Fv zi~L{{o7{nJVrF)}BE(Ufs>;l)KB?QzIn>C=$Y6s35Ewe$;Njr`ss#BAWblcTCmS`c zbhllw<_g>^!f(36TPEvb+K#zMN@iG~mW*CEYq&TE?M&^0wlQ`3t|4v$fgR|1Fh>HphL{cp?s2TDL- zcRCq9uvdBR+qRQU)_5A5k6L&Cu#Cn6rVdLD!#zw~YhyYtLgB32?p zUk71ECym(^Ktah8*NBqf{Irtt#G+J&^73-M%)IR4%e|d}0^I8-+&Xp7xPmV~B{?-!u_?mifLlw(Q(l|Ki0os}cIKGr z?|#JRV|b?Ke*e$UK`s(W(ajklXBC#39NyCYv#(>jRaeKYk6kZ5o>vRr8fSla$pWiG ztbQLhP0HLCR`lk9+UoNgw@-TCsJ`R$2ivmiSym4;<&0{&3peI5@996z6Q8^#a0P?> z1I@$XE39tk9BhwB`5|<>*4c0W@;O0Lk0p;+7Ces-3)|Jx@qMf5qZj<{S1LW^Ep#=u kymdKI;Vst0IeX6F8}}l literal 0 HcmV?d00001 diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/lib/ddmlib.jar b/android-core/plugins/org.eclipse.andmore.ddmsuilib/lib/ddmlib.jar new file mode 100644 index 0000000000000000000000000000000000000000..c9c2a9b211e1354b310a0d285beeeaa35a7c9233 GIT binary patch literal 269790 zcmagF18`;S)-D>`wrv|7+csBhI~{av+qP{dD^AC@jgGo=`@i3RPyKtJea@X#W7Vo! z<6U#kcVLe3jAts#fkVK7fWW|jFd#unfc!nc{_ErK0sB`JB-BM1WfUcu!9kS&MX@Om z><0g@0`6bI_@9ajB8oDS5^Cy93X+cs(?EGSMy3TsIYzqK>A7ZAmY?i<#|Ot?|Mq8p z*Z;4xLj3)1=4k)Fo$!C^ApEzEiG#U|qqX_}RTur=bj{7}?W|4zw)gF$JKGf1-U%D-Hi+JnU3TM# zWZKBHZHK@17;n`3fjHh4`W5`)_tGRu302zHI*L+-p3k*eq3+3z7`E&=%pF7ER8B3C z>hktwRqW7VA5E&!`a|Q=T{o?LSP#j6A^6YpFN9@%_k7~J;Nelj!8<`Z=HqZnc`e!l zjt)z?BG7C)ukobDzznNJPQA51e8X#yqHT|eRWHJ8Es+@}x3H%770F4-L1$U#2vJ74 zmD?D-vl|WQHOVC%`!51Z8%?DzKX^d50gI)cML9iVnGM5ai*k z3308Pq}eIvltxgv2(=Q5?3e;AHhgS%+5A-nT}u(dN}E3rk^Ik#gah+h)tMG^Wfy;_ z1JPAUzX`G5>6}YZA4{4;nRWy@v&f@#$r%dW3$0m(Di-*Wjj+gYp^X@-{=q1Nev^ZN zG(yL%a5X{^3ZjD-J%zKFxG1;oh}09w@sQGNqY*8{o zpOwxy%nl0IUBU`TWNra z(|}4I*Nv~hM`(&pv*&_a^+La%Azb*h5!X}R_67&kbhUXFGv5M-%E2K ziu>I(FJ>A;<0?-kt;Eh4BTkI6!|t%$MF~!7L)5=Jer%%Mh_ilQh7rPiv{dnnV;;Ex z!l<*>OQ?iW6?A}TV=Gg#rciFBI>nnbG>t6V1f|n$x!E9?0X>(YZMtsGB;T`Uyo300 z!~O&tvT2k%xQ4B72sYppTGjO0F?*dIe#FMM>B2{|n_s-Qe!qPHy~ zG&Jq1Cu}+Eu4!1FwkP4D<8pyEsU~Zuxe&OpP$I8>jClI;2rfGuow@AlP2~gAio=~J z<%LrW8RyZ+-c7nd4&qM9tsOlUc;_kmHdwgpp@Prq#4Z|Os|(z{i^jf z&L3G_76}w>c9lN=X>Hy8$V%MP^1fR?(y4>gA8n zeKjCi8F_5(i3r2-xl%bbcyje)t0l9YlA6W*-jUt-+x&tWmd-v{Ztebwk3jwP%0p4BQimz~UCesVMnXTurgcgo zcE6)(E4NhMC4EAU05|Tr!>@u8vA2l-1~JjnOKh?qf$TtA0rk1INw4v^B$?ycLcGGenwzujqTH$L-C3-jtZMkvt<)IM5zSrPwd`hjS+T=={qVT zjU!ihOgV!`<~R+CyMcVA?)~;>2qy0XL`rY~LUGZy9QGC78rs@W`gg>>*o9h1k*{N_ zAXEB8nCu=qR6v$2^rC%V$9~^f5j`)KGp63*)4T_f8n0yUCF{ry~nO+LkAUZ|+ zt8Z+6Orb<9*klpiIg0iSy61fR68_gri`E-~L-w~)a`{{C5d7y%EAMFePiC#vwsliq z#{8pr;%w<~Ly;Z|ixL4v%0dweazFy%O#vDi27wMmu%zo~;gA&MUGfNGR;y%VYrm{( zq+Tsr+gRI%w}FDV7{1b``LWWr(r4hm*?OhxUhAQ=bi0))PlQk=jJLAwneY1Y_9A2% zXqnI7b2qzG4pKAfsPXdvihFiEmM%NI^o5kxA?aXP)o)}bC_8$8TnclmNOTktPN+DvOPsW!f;oPN3yIu)r-K=9EG>aZrA@pGk3+(?Q_FhPbM2i2)@ z-GYdtI45{EAZ{RuT_MAbs(hpiDkfna zD>55QVtWScW=-*NDoxrb6lYc;(Tk#2XW&GyQEOl)2WF{8UANuRRM5gDegPezq@zb> zF8&K|GQ#eZ@A~|Yw5;(T+=BvFXJzBgdi#Fod8-J#`@XO`H#W3eTY+9FpZ3yNq4SFt z5l~8tGF3_9+$ffOn0zt6wHI-8|ILH_G-(?W8w9wkrrx_;N`9B#iy-^T zdGTSQ){5H;R@D!u|$b__|7*`fR zndTE)sEIX+r533cEa6z33A2 zRh(ppweC5CyxFlAQzFHO7q2c~!@?}@b1-&)-u2iyq&RS!MF%@tzjE~zn^aGS2%(Gf zd5biW5Xf1x%op!>d#Ma-xX?21`{D_Gug3H<8b-bo3z}kg#QIgzA85wV#yWcNNscHy zrbO`y6gcLryvR}(Q?M~}SCGxc5|^3_U>~WZ)YPIPjO3CQN37o~#|sF{r}xqrX1>e1 zdMgb!vVP?Z7&bG?zz5T?B$*kZZ1P$mr*(Q^7$J}S<#20vakimo+HU6vr$2#cXTQ5J zxY}o*dwsRNv%WrPH`gdV%h$!98G&fNZg>O4F4Y%If-dg)hI$ZdGSeP)lt)pIzXR77 zOsODRAt2dJb@8TeByz$Z8wAQet7~Lb(3K-ZF2$;-e`FibZlcA<>g-gmYR8hwWhUS# z$!3iZh6WzOF6+njJGK_Gs|hf@y3MdVs*jhRy=MwtE2ojx8allk12;z^PrkzQ_zln< zX?aa;iK+X0CvuGuPnWwnb9))9d38d3NzKDOO6IV8dsOYZ1l-c`riS6URiWH(UzXEy zYG`xXZuH~X&-)5r#A1-v$-vQv*w(j~;;8yrWm!9!I9*OgcZU3BDWb zD0K}RL0ySa_+!!9T`2TAMRx^+--&eB;Y!@r;el(-YOc=&V|gWZhzqtr7d$?Aw+c&I zVg&_z-*jMcqatyAO#^1u5amW@2yqT<)evS@1?9$iD0T*HRRv~N2j%APm2ksv-H1BL zigaMZ{#s;kN-toCPB%Vz`v8Lv-}fPMrLn!#6oPCg6mwFW;cd`CnzBOZnP%t@?(CtU zyRSN~;DZlhivFhBJ>f#ZdENx$%MgV*NdgHCn0>$YCHrhQ_0I8Mt zy}Y+B0D{sb)r{si!jLp{7wFyLEZz ztiOb*!lE&>$4NCzH}sk58Zfcnx>T)u=?)t+`o;H|HDh$e_)<0Q^fpE3c#nDdE?4r~ zL}7xKa%y>{q|$&FL!|bd=H$`+nsvJHNAglHr6)w#-el{OM$qu6snw?yp=%#Y$2Z;} ziRt>)8;#)r-&Cz$)8$sh9#%3e-!G^21T zVLmEx%SB+Pk=|BVkBwpsLlQ-B=@HEhQaP~&6_WYdF~1K8N=DcS(16apcl#D9(H|0o zMA-EAq1r#xia?p8IK6%x)%a<(`EAd8g(T+(V4%ef|{`U(YjafCH%2PGrpTB4o4Kdk6hrD6G931DkoIDSFo2KEQa;< zni7j%Hc-(Z*?SnuYoD^n##6ngZ}J<|(RKF=vE3|Zoxj*O`d#Z}R*`&_>O%ma(X_Z} zGr8Yvkwdxwp}?WUD@J4sJUTP(J2Ob9_vza}%@K@&n<}ozG6b!rrr$e|snTG9h=AWm z<(FyHWWd5U>$J*Oh4g=;I|FKo>^{+TXlo8uKPoL{FdeT!no4Z<(58rt5b&> zJP9$ad6iX6x4J95(FNK<)!-uCeN-rJXSU!X2SeJq+SwuxoXC_l<+a(OkzTo33wDfs`TiV{lZdIw_taVz@VPjvU@MOgN z#L&d$eO>${Uyy6cPwxZf7wOJML3+~Xn;k8i-YYlaP@ajJo{yh9A<)juM1zR}&upJ< zN#%H&8<-fqu`vn6DyuraK7zB&Obqz-p{?5CZ+DNqJG5Wm&Wv1k26smPT3e<(zAxSV zMW1~CcJkE!xwe%43wGH7)U8|qCgv2Z|CE_O)b-SszG3`H!%|%&DQzhTF-?Rk)z`gK zL=AGOF1s5TO>F1Tkz-M?$s+rju(ZIy$zAXa{4P9xZ>KvFgd|`-2bjxy^ryD`wVLlJ z3^Hw8jE))uJR1y+VU>>p$cj>h)`VK)qFIP~Q5=~y3c&7H#&`&zkGbM9v(S@Lo5YC- z{j4c*$lQ9tfl}YDH0RiKB3zolksM`{%k1gTG&{>+w_nP(oGAMVeyt4D&N7&_^RBZ_ zcU{dP*aX0w5Qeb3RhP|hU!QHCL-E`}-CoMs=T+`5_84XULI9+np(t^?&^QqJAz_91ao46iJpK8px*+pG# zJaGILQ>lJWsAxqqRBdm|>8gIzP%F54>%U8p7K6~T+jg;0ubt@iA5UPZ-^0Yh5MQV! z36p@7o1oVo0~2$M-WbXALS6j>d`-V712}1!%do^;t%2zbxuP+BX`ga3@4rc-Jy#Wa z_oR8<@}-6bW7b3gV&B>1PWa&942wzjW5F;b!V2+yr%*aUmYniJggtrl@%nH6AWoj? zqdN@m+z+xLlsRM+ctsbY2k0!h8&*>`ujASn*f`qV4sqv}ik!_?X*j6uPio@Ub^P5t zgQ#5wLZ|I+E<+~QdTBr_+kIqdU6`u;gexAOm=Szml^A|ZLtI;zDdQ`-9I^;C#enIpItV3>L zH!d;L`_#ys8N|6Yn%eda5J3Eu!<=sI^aJ75X(<8r25w^=52LIo923>-GNKtw5I~1D zX-HMs6dRawfHR6h1XD!AHHrA~1h$Em6c-wfR!V&D%NB((Ng`NgjgUaK)EKcFJO6J% z!&V$#2Zam*(m)0RLi2wM8cl0}r#QgW{lA;DR&7{geJyuEt>(|n`*cdVwCj7i`URVP zN<~v>$_=~tT11(&#>Jm87Ja3SQjg&}IUVqbdkJ8)2`E_^i)q{Tu=cq>%}|rSft4gI zw*0_a#M%I3oKL+ew7ziK`Dm6QTAx*A{^~#O>(1%!`8ds#ix&hn?!H1Qz(4(cCax&R z1i=zLSFvDr;jAJkgui#_46X#RH-7r_=n+&Pvi#>Eyv_S4NMC3<_^ZDk@AvvW+}3+& zo9k+uU&#JmD+9!p`@|o21qZLxQ3tkAeRVs5cms5wnRx!g1Ki-+;im|n-XNFv*ie7i zzQ?|%ZG5%`^}`v%|JfMWg_sY$tr)$e%YEGw=wy6-A8yN-T11%tdsY zIZLH@g-l)KOi#%o78#m)5gEQ@O>e&kDKg_V-BrxZpUkib;qyX_C*bAwIo}r$6gq|V zj-0L_IR^23;okCVe&BB5cVS;*U1HvEDTrl353*cXYPQ3DuN9XC9aWO+b37t`jz!Ow z{!8iIiHeOXIx}7l2JIq4iLQHPVgT0}Cu12m2jBGY@4|&fux`~8}Ecu2A2a@KSeIdI8_E2U?P zkiR}?u*@5YOzd{1QJi5(Mld^T9P);UU)J8{-3->2g`eHJbZjWuSq|4&YUuvniLUJA zO~TWHFAeKebw!;aS4G#(p{iu;y8^Ct_hBA~P(vnlCbO@h0Ns(BozRI!7?)I;i5Wz2G}pvUyRCK~~-$wHTV>Ih-PP9nhy z1jVz2x|bWhgIrNcrTJ~VPNfqan*vF^!9p4^*xmFXb>oyj6FQ-+Olc%ZaH-LY13;;b zOjV)FfEsC8yy7&as9PyNO*TE|1{!m*fG~p#L+##!>$NcO)wqb$F~T=Zy<|a=JuzZY zqkLe_KuEse#DkgFh!brP8JY`giwwUqw;-gyae>oka-3a zJ4-z{EFJ#9Bxtp!M|E%V<+(jp(^4f>7YPSyI$Q>n`e>}nP<6QE4VZ0b%KYf`(EIgS zlN&-Aq#mcLlP$kKZW@O$IGGN6YzuW$)T2bmi8YQ!J$$dtku%Bc*00-}5_6IAd{UD- z%i?gwkgHkT6#Ih$7dijJ64Yc%t~7WEwp8ljEZq)$ zrWJrDEwjxtZdkGjO@GcY|%Tf*JiVBQN z3E*c1Oi``dO<{F9Aoce;^UDV)EZWINx6C#+tj;#+XD(7*m)WHvm4$+{;@7?nuH_#0 zmYOzbc{eY}o2ficQ4yu#KWn#6I+Tle@GD%0Ri}#%#@SY+Kk7+i%5gU~ zSER_g^37Z`_v2va%uyg9V(Kw_%)y^r6A1GX(XeD@PX&)vyC+T$i-z;9Ce0`mMx_?V zOms>P-FFbuPUtOQ*ccWF>n*4|i``dbHnC+nGe^fd05`Bm3>$s{EkxmPj20Eg2|m|a z9*b=zSy%F&T22hD2)Q(BA_0V^(pm$fRT=J-MHlSRDs*R^8=bab(?EVRI`)~dYB2Z7 zDzQv$eau(8vc3JZsSNc#Zvz?kU8rn_OGB7uM6G7YT`8pUEs=okI2P<#w_lq1A8S+2!=BNnQ( zMEXs=`F?n)@pB(W->1Iw7*$;mObf{&PT+6J%)^$WA~V3o2H}n{e^4rJ!N_P&Mn)Ml zlQGotZVkgqa7s&g=i^V5Zp&AN%;d`Tm{qj^JMxyJ(tmN6yBC-}eKa*aRzEQvPqCA; zdWrz5?BVwF-C`fV1LiBS4IT!eZN_xL!E^q4c2HnTQ977FW6soB&es-+lQPKGR8TiDHx7o8$S0+UJkuqVCKiErxd2rsr-TjB?U7L5HGTbR zJeOxwkdA?CSLkM~RftaYGBY4~u>YrjyA&<)lk&&*PJz_9BLre?;Jk9l+X6!xMC`Jq zamU=ywF}njbmro@Kqfc$nMRszh{48Qt@g-iZ^f{`cF0E~EOG%RaRGY(uJ0X*>nZ8j zEi|razv!}X2`G-UIun@jp`}}B^}^i2X-mG{r+^fbpbz}Mv)a%#9ue|!jFneUiih&B z*&enuFYGV5vyiy~1E?K3(>;5r9fv)R{JpE2bZK?(_NZ<)C-)>a?w*k1Vw>F7IaU#heK_P?bzeF-3qW=7`P5N<$*pwCn2vO?8+{G*!tW5Ft+!E>c-Jh zGE)IJQgT|{(*VK6BM}2Vg2#g+37qej9X@cR2RU+719{twe)Qnfnhp-Ytac9%5z?$H z*cD+u^VStPgGW2dYQA&L5c8+aLuQ%%?pkAVsf($ooe*q1}2m{ZTe($uOzu(pf%Z*DF3r07mZtHaz7)K4h(SLC+1{!2<94KQcAYvTS6(H6uk^|Cg z^HfDVe@xI)EP`XEI5QVabIW{feU-Bj8|%2H;1;>20IT$LH!tw?-(xuXweio6~-M*G!%PwYo{a;gci%Mr1`VU83h1x#AUcMq!J8{?|TA?D4!t83(r z%DVr?j1Em?{yUT6kgi(M@Ag9!iW8>u4ZeZe_0X`*8>wPnBjB*1cF~?u6ZtwFd{^rs zz6F|G?>8T4rd-Y%bJBLprChpL{e*bUBh$V`c}js;$ zmbpWfXc#4hNrh=Ua>`Zx!*HVtp@1|PgeIbfG{f7HuvbB5iHD^s2gSdx6 zph{no)03?tQ_+*;RX7^12bHwgzd(;o#Bf_ zp(L^z_NnjT(u9=)WdBUuO086XG3WE4XS9*%Em`b_tx5jjrBP5wfiD&~pBspgXV%@oCOn((u zRBaaT=5=L1Z&vvB2KX`;B?GR~r1rZa^x_-%d%o96p$e4y7e5(P3U-HW;je=^j=+^! zkatxYFh6^#Z$A4Hge++O(8GlltRe9CyLJBv1p8M2in$fI!VL-n^7vP{{&O!aVekHT z-sI>a=IG$+Xa}HB1h{!Ry4e0t;!~<}w=B2{iePfM?ZMax&Tgt{A+5elH4GXC7%5ib zlcy)M&*4pP6Y}U>6Urc2L@6=ltbu(CYBsy;iSr4|Eip4Yh@joFibQ!BoF+pJpSEX% zNJUw@Y8V6_N{+O-Dl~Fvr&+0nhbYbMwGvEfA1q}duexsB!&>@e=^c4pOE?wEf;h$0 z!>uW0wWd`Wlm+H#J?u!{VV?mSVQLTrkmOR7xfGv<6zafP)&?*cDk!dh-V>6^BMu{Fu=`juH=zN`c@vS$;06llhnwan8N)CoJ))08M0uy|H`_7?{(@C zFg`vXM1ORNYs6UrY4w<;j4a3*l3RUigb{NlLz$(r2($gzsT|W&L*7Jp6u|;>nl?kr zDSm&Ae)wkhVJoU)8K>(BdyJmk5nqwBzlS)-*eX3E%MlAakp2$0>z7K94k-2Dae^AhaIA;_`0*2u#~Ok6d&Bl0Gl zDhq)Kgc@@+tM

-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 wl^Hq}~ttU$AA-&Kn(swpA5O1a;MW`#v)4Z74 zk7&SBO3FfA+Cnp@{>Z1XuC%RpKj4Y`;)PWOAAa~1J-q|^BO>sydT%J_=tJ=kfgE_-7V`&PZ9yhX6vx_uA=4`X~N zBfVW$WG#S{EIn?DhRkPpo7()o9Q?=kQ}h%fH!a$Otd+k@#Ew1o`G@nUk_kc4PM($v z@8v0o0a7f>A<-tt+a-=%wNXR@E^36ugse!#&Ca5x?jxkB@rhYP|Bnuqx0VX{MrWd_ zdaPf$zP$#(4_lvIlE?US_a%{j?Aol-1ZE~Yl^K~HYOiWEMeMU53;X-m8*Z zb{t~NK{f9FPbyf?P#BN)b+|vncArT3el3F9s5+fx|5F2L9MFlu^SqL%d4&n zWxgWZ-=V+Mp{SL{ zjS~K-Hj&zqUr|A2;oDLl4#!Lr%vgb))MjX;j1339GVPF~zi1I2Sl_WZg0J%Noonwb z6uwB>euHJH^IJfFXVVfE;1a)2L(`1UnpXV4`IKMOo>{5*3W=$FS8xB~;3z%6x^5dh zKLVj#IR6z9TFn2*dUGZ5zITs zb^M78&Z8h($ed)B5Ewy`EULf>z7$I;k17M~muSwOEgJ01=~=pfJe4EGWR5UhXyS~z zyx`qm$wbp9LN~AVExkjwAi)2_pweZ;*Uz!X$v~OR@gX&K+ zxtvqN!qrha7Iy7SWBRa#00K)o`F9}~j6}Y9p%2wDsWAPUdn}+L8T5}z7}E2fIE|w6 z!jL$BM;6a+c2k@YZ}e)Ajc4YtFMbap=<2`hoJvnheNlCO2;cLv1Y)`A9e41o$UsB< zSS|BuV@lcV+iOTdGsT=l5MdlfVJ6{M#F0uJv^xpT_jyH;+d62uxy}wTW6fxK0MaZ= zLiKHCF7J)n_j**v+!F28%x@N$itfg@0Ru!Lp$sae*6| zj^=>-3lh%y>KBG}v>|bG!}3u_B*prcYTPx_5u@}B+~WtlUY*oEq~`|+guA+5sC%cs zvqmJSrD|&2nY>T%#{$72#c+1T2*K4!xx9$cRjQQYqpM-6l$NE2IZ?F=bnlpK5fwz7Nr5) zYV&R;(vzwY@df+_jf%(v_!1(!%nLn*9QfDP@-O7_sb5%sphWJ2ZH#*KT|E383(bL%>M*bL}O5xYu;m+c9Z@nr&Bz7c|UKI%Ho}Reeymf@H zaJpIRM4I$&u{$b475@oXL%wa4 z1!MH4Tl(Q0d;Mk(z|Biv5_~)t(3IrMXL*80K1S$BdRa(*BG-|N^_Q|ypO=MOBzWSM z2)(~W@C?x{$~}bUeFi~y<$Wd@Tb+E;a(SS7`qM3$J9qiS@hy1o3P^lQU!}wFMqU>% zMBYE}g-Yxag&w&AR$+osK!^BL&R6x)>fAG!oWXz&YOzJF6O{`#cm#Y%78cHWXa|C_ zcGgU<;yTY0JUh3Py#%zBTjI`0K2v|5Qa2kW`Q|=(O1_hY#MTIv#AjK$Z~-!*&2L~G zxnGujG-kggJNTw$Rh%xTWE2b7p?-KC6g`mNewjCf@v9K!p2! z#~WwFsV#$%=FAH2L&V>UaMAay*b(SjxC1kA8$&T?coF%qs(*l`e~^hkxyhWf6HNFG z%JmKF<>R-#6FA``p8O!AX+9Lc>zr~d)gZcB!hwWD z?Fof#>?g&kPLflYVT4sbR$`d;a=~ zVa6(by>5yU&rIu03weI(P;<%pftMH6*u|IF;UbqkKV*A=rCL>Pjq*s9x7fh9ahx@^ zMlgR?rcfX?)9H1?=3&fhMt~vV_K5~x)59G?i$vLZ4nNK2*CYxd^B{B2kX4!jgMF9x2i9kwmKl_P0d(|)B7;)93~`nr@obPU z&$$)EQo~@v1hCFH=fzvY0j|*yJO!B-5gc&4sb@-8>t0|S7`ma3zxl08XNQlU)Er2> z$5{rDSNqxZV#_{7g{#=Y4IP-UpJ*9WwrEcxxI~iMw3x%ahEyGL+oZSXYLMZ`Q8r*D zw1U8Yz~uiG=ci@-uF;XrxOr-pCPWhdjA|CMD_P*>{U8$F=DP`#d}NK$$thXsoavFL z01%a$1HL3*jSfjpDEPq683vXiOzcHe8t&h#6sR88QCXuIO*?KhTFy66Yo~oU@5!e! zS}#v*J3vHVbrc#5SqVmOHzo9r@ym|7ZUdpmC~;GikayjksC0a9w^qu2+XGKyLR z^PhLAa^fw6&@}mHZwJSaoflZbFsad5ltylzC!lIh0c~d8`&)vkTE|Am9^T`TYMkx+ zPsU=cm6mbiAUr6m)Cy`(J$pE(SJ*`$xRQwTA5etNFxmgk5{k0}^4)QxgrS)?xg$ip zBFZ4p4UYd6W*WA-1FT(yY*Gu?n_oYGG48<=c;E4d?2_V|>yhXZV~cz}C%CUS9ODN6 zF?TNpB`+@0U^ikUG!4I_g{lV7Jgw}(*JP_qIj+EqVYo4fbcoRL8E3(9czQ$WFxM=M zj4tEB&{q`?x-Kh1J!NPNK^C>SLcw-be|d)2(a@0x2lGUETRnCa`U(e)q(l0*b7KGx zSt}T<2JgyJjv0l)lmpSBQ@SbDux=O<;SKzYHo~{x6&<|i1|kBVjwds@BhkpQ?)|4+ z5ld&fz(Hn-LL_mHFz9HNmhM0f?9F4_ar1+7{eu&Z>!oRK86_oyT?*Sl%+ZG#Vh3T; zN6TENA)QUwY$pt~>is1M#He$7L)Tu(+P?)%Qv(=v7OdmZBELeZEJD?fez^&g!71gV z2l>~waPpk;^OIreGd>L`B7DCXpELY+97488T%x;3)TcNRR1)oY40$54T$y;$Jm5-( zgfaVMjJHYHbWV{YUUlrRG7Z?AWx9;jF~24m*u2PDuQF_j4Pz{AD$yAwM^sJ9dlTAs z?6}|totQ!=wp8emxu;@GVeIjF#PenUiY~JlbuiD0dA3jQ5diC8@}8x$OO+kgfFr5_ zOu0#uUGGJs^(+~~pN}C_e6p{ZleFvhXiwZi>^1CpfnaMM8zkZaHM+Wrun~LtsEGxHHqj|*?2OQ*k1g|L-PkNpN^IA2exIE z)KVm_GAe2yM{A|Df0a2icO+YH3-Gk6x#E1j+J+Ro<8F|X;J>Y7MEE*6-fW9IC7o+- zY>U0%TR6CB%Qm{b=lehJ#>z_1dsI7ef%rVoiuKR@B_L9%Xc1Y zoXL2mo@ZSby0vdjx^nWjvYiu0%Lh0>iy*)Wu6u#w^xcAV6Ui{b4$x~@H&z4Kr^AGP z$%X8QEo(y;X(oS)xUpd1@HQoc4kKUOMAjh@l&<{fgMkK8VkF)uL$NsZO&n^ncjY#~ zSQhdxN)iH?RZbT#z1;$OTbFPo!}$Nl%7Km;wq{E z?rk!_IWplms7iMl0=OLsjweKz)m0UGv?ULZ7}3LynRNyL#*>w))D-w@N@NzKPA4!w z;-BVW+jDG_pXovWdJ5(U%}&5PWmy-gnZ)fRW1aJ06u_A<`B1XW?;fFkNZO((=1~1x zJfG3g!E-ziG6?EYi;hbT3%Bbejp0SKKS0*Y?+~VpQ4K44B(IUzqGles90+=Jw@Kc> zzfNEc3q6?5)4GKVJ&YbucyzK;-@?yMZXd9Cq%NeY0+TgA0hv%cL&_(&@gHmO3C5|H z4NNQ_DZNI}mK_JHwuzHC1DU%cJlS#T5YeKh`YAe%S2NNAg;a^^#G;Qu)iww!GQ_up zOBsYX#|v={vjz>w5|pF(U&H0+;|8@`skFRMbW_ld&LsSV?7c%~m4pwfv#fWVBi3$a zbPv3wqY|qXm{fOBwmgWbkCWa~Tm;53H7|lZDmZ$JSp*O%q$Y0=z;R}L0W>Ru zlx26gI8;X{c(kc&%)HUhv(-akuQFVed#<|q)rGL!S~mB)SPD#`;})9K`JEk_vsz+Y zj|+qQymJVI9(U+o)!6}rY_KgzLFHprpW9q3YbdC1$wEQBe*`)ayZlL5dp=tQ;OG zPlHM@=?g<}jV=?ulgV!L3n+JBln_G~shf+w77awg`o$K8Mf1SHSRasUHZ$3yW6dKR z8z>kmiM{ToX!3#t>Nrj)OPX3=+(@gSe<=wad%skKY?Ht>m>us32lI%IapRDc?y0zW z9v*mi6r{XP&Y&f}c_S+MrS$?MD@+$ErTvnK<>h~Yc&HJ3iO&NQ8-E~CZ*vcByN)|} zJF^5vQL^2ClEym@y9cCI0if)ruL!P#=`v$oLx`A@DJ z`QZu8yNCa+odDxHYSo3&nRx0RxE3;NG6Sv88Kan3q z#!GQYyU=`$NcTe^Vf_fO3+gUxCUfePif&32%>JSkofK>h8BELDNl56@N^VND?s?${ zs8w_4s8^1ady&JD7D)|-6i*n4<4{93XI?;*1s_9z#F1R}dKL0qg|pE)qV-U!^8%N` zz9x3r%5)0`@3x~DSRxT^LSN`+++tuu*=c6@D;jjJg~-+0r^ zRBvSW&zHNr!+adz^ulhm|D`z-$jZX!(a2^Xpr*#*;!-Po1l zjQ%C${E_0iTF>#`vC=u+M$T1}Pdj!nZAAT@nCSN+GV>8xE=;Dlh4JZBUd}Wk^`Y$% zXBw+cU(%W^>CuFnSn^yTr->G|>Nc-h$)s%JRW5%n7SRN?S@@VYofgt+{^MMlBGTE6 z?I}JPO+uf3x6g+F=WlFe5yyB%t67AY2B4`me-PG}Og*Ve6T?}BomB|y@@z1Svi50v zPmg}tq}Q6I(y;~WW>ky&OEOVe@1~_1DLU zY+A)F&Nh|`NZ}@vhIt}p9Z+yI9l^mm%>^K6hn_ zCSRjomFt@5aM5Iyz8pd)n_j(uT+Oj~5&!HBnZR*(y&ig3w2b>b;SWXlGm(Fj>W4X6 z_6W^oeyq+A#*5juqt}S+#dJ0GsOIM?nMSbKOBr=l!JYWMfiN28U0QX{;OS7#C>k+$ zES%Y3iAsD8P4F9Sp>}nKsHNGWyA4aSrIkfY3#@WwG*8g+0{x{#R=Xeh6G;3D`;R=ZwC`WIp zfizhBFAT?FY0alFdp(#KGw}_04i+Hm?A#M_5C};_sZO?@?)!GPo?~ zS0@NBm`;%mP$ezUTdGorD+uuS#+s3(-dyH4wbVi$x_zMG{x1Hp(z_etcU(11Q=uoE zUO~yGYuAz^(S%!=>`IqP)B0X7lr#DlhKT08()Ckm#DVGuvaj!sj7D%7pV7^>01q;0 zE)yvY4jyg+T}32wPWn$O>&N8|V(8xMEMW%V;76-$Lt7&Bwd+OUHpI_W3HCMZ7?H6+ zXKslloSz&VMNsLVh(BZ8E0jIbO06T;kk1$lOJ+mzxdw%{bLR(n?pvbWWMp*W)Ud}B zk~-W7qphg~Il>zoRI{9iLeAIDM(rFuQsCxP{E=S7jj6x1VcOcmZKKjEI(raOWw zd}Q%JW|0@ai&ePYjdT}m7$wfXRGS4(@m!bQ#$tt zLhsD$bo=YJ+w9*N-&?PT}p$?Q-AQnBDP+1n!B((KPOvBPVIz*&`_F9`vD9X72n#T(PvoTWSDSiMF2 z+K^hSH&8yGM(Evv)^u+%F`%6N=3B;h+{D|S*vW;Op79a?Z#9*(|+$K)QE zRUL7tJ~)x~>OV~4|3w*k6!^-(_=_}T=W%))@W#{lEij1Z(zG8If(9&Dc@@;mm_B)h zO3#@fLA97e->5-F1>hfXHDNs^t%WtEJu^WJQsQdM=HM$Iy3NP3u_gku*z3WoTyg38*m zICFAktk;^nZZKNaUh{1;DQ@f6R&6Ys=Q3~w_*2Be|FXpXLOf-xC*?*fl=+^WeI)~t zR9rpfmp#2vPAoE(+gwWs>bo^{AJ>@P&D@+=Aa0~b%93_+yltV0ii51r)k&DWqd!u^ z{ONANgkfz)Cs{nUCtsT!`D~X&IE3kmYku2`{6D(>6|p)RnI_vMt{zV`>$6`1d;0v&h{x@8pdru*6C4h;FyKih!`Aqu|&%^;A|U8M7$jAf`>CCDdqJ zY3FB|JXH?GW^+&70UVswX)fJXmX-8Idixcd*d!fnQ&=TT!^Qa+%Ec~c;`-vvWi(uN z={iZTs1s5YFH4s#8=mIQrji)s%CurP{21JohLhb^CSRNFTm)VP9s7FwoTg^Uk*8)j zTdYV0ZVc9+E=&RATkt@CSeSt(*Tux%QOpq99W{^>JCsysCO7F!T%G+MZNrpoyJeBlww5_Y zZpFM&JZP?Z{;)V#R@Q?O(I;XiK|$f2G~G9V-(8t2vWBS8Q0-(tg_DUbE%{i+E>J`5 zhB$VGND3Sg=MebBBPC0$H^f-Rp^o`WL;v#^Oj1r!ywbVTcvXtrBUyIh8hb2w1S2*SnsY`eT)ddkorFmZHJr;iZ2>ChX9r!8uH8FBucgQR)E5IpZbTV4DBp`-{uQU*Z)VG+bOyvYzHw1VxPP}?j& zs0L8$fu$t?lZ=@TekWIEBWTtn6p1OwOreL|?3SvK@*Xc|$AFfn=YUN7S$m^gCR=GS znU_W)0i_6vPBrP-?A8hcfljXr77k01KKBu1mTQ8D_>dV#vgyi&zIf&1 zg+>Q1uoJ~;_#Itp!ZRs6ESjO8k-k>qXhvT-nF;=Ry-x1MD07@wSKOa(Q`hWfs!=vM zj;QYYHcp_G7XhYt8b`~esp+f*_bj-KAd`e10G?`bV(`jABTTmVyzijfgyOmp6%ysj zN55NX9pK)Uz!a+o_qw=y{S9=t2tGj4+&vTzu03pI?_6^G!tUKE=fuE&)c5A-)GR)o z5s4ej_^nWhFj-aE15fH8$P!hC`^qEUIza|Pg#Jdpw+aM2Su0%Qy90NnCN zxcn$q%+TVh$?zGWu(R#EL)VOJ0h*hB<*mqbVh`AT86@5)Q*U6kxtie^(zJka<|317J?H-M5C`|v3 zB)&_^ok$E%&3ZDDai?2FrsuvIjGO|z~Tt;s5~Fdh%d24QOp}i zo8VVY5-4@{&7lE3H6N*?aBxs#6Z{J+#)IP~hSUsYd zJ87529e*<>k5e)8>E@XvEFd~JgY#DFlu91efG+0|d!v^Mhf8|_!wi~xV=q4PO3)Dc z<;}^{3W8{2YST22NtmN6_dBb)ig$BuFVIZ|=_cI2-;4KdR@JN;W7ISL&rA7FwoS$Zva>C64(N~q zYMRSl0j(zeGpWI4_oE{QPxt`*7V)~yF)%4|1TPcMH_~*?Y6n3TVv^P#WOlC?D5W+g)%NhN;b6hCasOdiAcK`+|4<7!Milx*e!mVRX2ZR zel~Lg^X0a9RZVtlfmd6BJ-ouLRJ6@2%0H`o=_x4* z20)-vXM-O^uJN)9vl?L54En}Z>MjdMZ zmM{l5;5Xw3pdlMn5;+HfTj!~5@h_)#-HOE~gH*ZCnYA)kqllo%D=24Bv|X(xb2Lj- z!Ch_OI$fFd0S-M+XQiJC^ch(aC<<@k1MbC#xT2FsHCU%teX!|FEy(UmItT(jZ1LvhHKWd3}RL|Fr~|AW88gt*vQKE0s z-_rL(^Pj_VAwv@dLt|^x|Bv;jFd#Mb6D`|zLBzOOt0vH^9I~P28(o}2fW`-oOw(hZ zNLEXZD~Wp7#*5DT9oZ(ou*1VV?Gu=7_j1^qtO>nM3k~7L}Y&fOt!0|C+f=aH8CZf|xo{ePX!|Bt?xq7Ln&GJ^j_-nG`Y*8Q_F-r~nk5NOi1 z;GaJege{_=eonbjnNM!xH?Ca?5j~Bedz(6!*<2@1^MeP`%l&2uAzIt4 z*(Kn5|AmHdypMF9GVKe4(8UPd#{CU*_|jqTi=PIS<2@Y~mgBA7O{1UcuQPcFN9|L) z#a;7NVUHU>cErr>Pokd!*yngmM3f!9nIC@jB~3S>oZmR#LE@?0igR7bI~{U?&vw-V z`Md2gcnm~f=6H;Uq}@fL`llS!ao=U4*74khqWYs!T3G4MpIO^m=q&cO7Bo~iPkRBo zKXiQ6e09bPEGVBy4+kdXj(~W^%uDd@#gVSO3V&wy2Or5+b=RCpKdf)8<7V>sZo`LG zYxFq6Pfy#%z+Y{*s^#%3)Hik*s-A?#s@h)3=ERtLWT`_z)%}d+-(J|&7AB?XnZA;> z&Qfz>_fD&`)wr;|hd#u;eM22k&~;|a{n3u6Ur(_w{|JDp=Dqrp*swx-lt`6&o?80E zvaaUR%HGWCn!7ePGoW_8KVB`CW~>$GCb*UHB_b3h>SDWQsDCSD$4`G5WB)ChleLKH z8Bq9zVT+_N1Ye-gypadB5b~(=JMH#c68IP<1RwBICJMk7i&1)NA3pN2A+u;^iX-1r z!DXd&h)}n@KR>5NXK;q8k?QH`&Z=_~2WB*HkWY^U6&9%LS~J|H+LY==l2z2X9>F?} zBYXX0R|6#)3{~3Ft7KKXMXrAv3w{ptAS(#DrbS@0WMutQmq@l0)fTbl0T8If4tW)^ z{iCLqn>`UZ?@Xbcw6=?LHSc_i7hfx?Pkwds?NC7X5)o`6M+Q8licDc74-ct|>W>99 ziCgOgb*a9dg9{MX1X3rLHJE~Nvq0i5{eVm?RWb|<*l%4;Hg?BJ0Gt`z)o^?27}6$m z${9|HFL*-(OOzPEKwF;~A!^jdf3TupS2kdAk;n%G9OZI2H#(JWd^b%#z6s%~+s|J@ zP1Q7#XQOjwx$}%t=Fmt`7~h(5C?FN{)Ah&^hSOKtTR~qLDVm~% z4YJGV>vwB2V}{#Vt_d~el0@Yd*IWntbA}3@U|!_keUgBrSY$T>b*i#uVs!Dl2Aj~exxBGdlaBCS%Q*56BIPmt+CN|< zUj3*{{%v@fo<^PpFoHhBu*ivqawD7fTxGiP%8L2~3^g@1bT#Q|ms1RJ43(TEyu15m zXMZYTaBI~~t2(74!mI z#+44^&*lYeXEP04rI9)X)Wq`W2PPV(%mK>bu86{W;wt$RefbF#jkC*ImDw? z#QJFU5;3cl4pGp_ovsm5Mwx5MMZOT`$zsh98U2PaG#=&rnRc{**L5 zxYPHJdtN82gC5@yB?w8FL=rBXf%(NnPq&R1 z6Y}{uHVB82m$Qd9<&wyG*7Wk0PpVC36JOrntr8N`^OYV~R)ul4f}xHd<_SO(kygs{ zbyg4~7l@%hV7783(WIe{i{bq9Rvd5g=Au76W6?7C;3{9=1I4CA8oKh z7YFTYlSB1D*>LQ7+eReHpVb%h_5l)`6Exd+Rm=*8`}Y_4@;gx_;*Tn00h@P&I*j*t z-RaySuSV?-55jDpW1Vi$NM9hg*sen;*Ch?>geO? z63Ma!C;{_ioQ(q5tzX-)r^l1Iqk0(ZP5PnLhD(E``C*X16y|tn-I<4JBk#+h_jMK{ z$rmHL&$|rr*uh+pJTIP4L#1u*4{%xa2r5y86X;JgvF_`^i6l}CMPyQ3JK?zZ=sDdr zEAifT#dKq=WEhvBMCQ{B8)$qTe;n*TFkfe9^?FE;jx)A=BCL@P;{GZ{dtS-mHv&{Pm(~z+<*^ z^F5H<#A(Na>5Ufh2^n!FqrPR!4y{t!Z=Z`i5Yov{n_!16$Loma%TOMv$K+ngsu;#Q zSF}hJd*&FiNHa=gV%1W7d?zoubjUtI`@`Z`*Vu zMa=~?8bCMfNaNlMk6I6_+Kx|fX}Gs#iM|CUGlCf|-D#71)ady_OwWR!4B2&$JYkZ6 zUm!(P7fZgbj*Z-*+BFp|D07R22%0i4u+{1!H7=V@F+vrT?tv8&M5$%)9G!|U`a}$1 z<2f9>VrOFfNcSDiwo;?6&qHGqX@_LQhP2!#S~}391^Akz4Vfebkq$t*G^H{NoM(`w zHF;fS?-}I=bFzb%?ZP-kJlc1eT6Nj==C6cIm5?r$*UNK8;t`qQvimPr4uzFdY1d9N zXe0{QI@Lu_l$tQCo=a|U&@pjSv@C7LB!Ro-C+8Re51k%4S!rteor$LDHdv1L-##RX z+vHC41IHT06;#pN$$VCYUDFaosc*p0c%tLXhX*>upin!u z9x+1jEOSCMbzYCYHh$fUz@xA7#@+Z@e(%cpQvT#h>GXue+e@@Gb_Kz>aZs!JwbOZc zxt9*$;{hjst*|TJB$>k91y>u0;@#0Be(sL8^ayG2NP%3oPLEr4cefnm*VTIBh&ST-67AtiZ06lE?k%bDx(}>dx^~eHkm2(7|w?z7E(k zr==!j@MiBhHZN8x&Al)p9*q!Ei6Ev$r7#XjAG|55`2Ayowz6_?ygfJ7Y<{xIRXRr4 zJy=arm9QXuR5A&MDaB}OzJH=ga-277=iKm6eqPeT;N=Jn!fCRk z5QNN#d}4&Q@KFO@L&&cq;I8E^QO!ya#NOG=7b8H;Txjd1DE#gh$ zSAIa>FZ+@Z>zx|XwLPditAiKh9Xfn9Ebq`1(WNd9&uUIM_<@k+jT|-JlRnaPD7Vza zj_Gjucu7Q?axfV!*ZQt32H3M>AYG&$WYG-WvU!b!XbzNm#RV+Vt zutZwiA4oqftP4hP`V)n0t6J4i8k0{;BVK=s+HR7<+GHVib_s60c@`|fGT>juw0RY= zXpOEr;?*s|_AKE7`W760X3ZX2Y{C}yvVCXyPkguIPO?k$lu4Yzxrvz98OI&yn5mgD<$6fMaVaPb!9pi^9VJddNDJjl^W# zk}1J(^{U8LrBqklc`h005iK%Wb!jbGx)A9SDNVXyXuicw$J`-WiZ?0%o!;a|mxZy(UaX2#8b)6Qq`wL_$aZ{|+ z+KDcwc3SneL2O~=Ds8l1`m=V{kvbDs$tJ=?@dS01q10)sXr5&~>53Cl^9oW-B?>!I z%d9~|zqXkR8|5hTw&_FEXDiXkG$}UAa~G_#tTg8f|4hq@PL^||hy)1tCRP8#R**y9VRk01=h4x-hYhW-NM){2faWo zdGeyMl)1a5A`NVWae&>@{7rDnGh+_HwE*ZKh7W;ZtAI0Vu*X>IODbk_rCF>PFP4jn zd_KYue-g{-PD^N;Dd>6E7UA2pu?AcCt+&KasD;Ocm-QX$?Q)gpIXd)7I=p1DYr_IZ z9FTH8HS;@t z`BFrbS(qkbjBSeCt794Us8mXFslu34OZn;HrgwoxtJ_0{mM#r-6}9rV1cy_=2$=yw z5ydC5<(Wm3X@8+T}rCd`wVErA)Q-d03LsInx|U=J>7AoK=;o; zX>L|nrDJY--D!*%B3HKK!U;*a&lL7xEmNXDNkhh5Khp}KKWe_z#H6}yvuKjF~5alj^MRae4tqs(OF*9 zRIa4fsn=pQhIC%uq(rW~#$!=f`OHEtC_=dd%X^>D>LJ!9!e?EFo%Fo@Cf28nDkh=a zAr_2#)@hp-cPGTnXEOzJ**1C{vi5}jrbklNpB^)$fA+djQzqq7$PU59snc@KVHA(> zD{{Y~B`SPw)FJT<25Um8V-yeEGAew}d#T6%VeWqS1YX$oaUJ&2oWH2=uKS}dhN~0> z;8;Q%M(sqVw7jWCDb>c@r<&U#p*((|98@aX_&)u%L%vs{X`NwyT${XQ3VD!J$HpOg zsSkbeqn>qVuat*=CDv<=;f7mpP5!8j<62&*cil=S%E}JWk7rq51xT1f| z*6JQB)P!aT8(Ta+j}-%9 zQex?Q&e%zCi_E21W+pN$n~6+Aj5K>8K=OwE?lf%_Kh@~EI;@w^K|VYet2Z=-dH8Db zU^LV!)ktbH*ftid@3d+RPg6Wb8LKN-mgrdQ2{d8*0#QLk*#1&e^yjEDS4x3P@pyN@ zslu3`m84H6-;WFyWHKa(%wQtmr=;<_JD21TLzjdEMK|UZEvKw*r95R@ibb{H#ffym zp*Q(F{wmD+BUGhWu|Za2G#5GSj1dP`OfnJSRyqPd%^O=KtIZLII)7aWM}5eMz^ZLN zo&!@@!FE3NEbf*`S&`+%LS}H7y8n6#EXuKS&f1{994A||)<~5^Gl=KZXvH~Df!n~< zv@GGa=o-0xTcI{tyVB;mVqdP@G2!&iG#PuGO~_>+U+ z0r}JvDU0%y3=2z;Bk*Sq>bz$aPO9V_!`7*gI4s)t_u(h93!zyF$N$=*jsIQ}krNY-K+>F5K*q z_Dd@4oBT==%t%6_5FW7Ja!`1a+FULz(#P01Rh_(Me_LuSi}|IMx{jqV>4Zzvf>&H( z_y=V0_2%Psf}+!dN3}~|73yl zZ%X>Vi?$M`h7SMMsFY}YYgATUzv5U~Zl+Hy1&zQ!LBNc#Z6ShywGHY?TamT;Q4FxL znHP8srv7EOjxDjk9d(viYKO}cabd%5SyXJRWXzFhg<@`b&MvvpY`M51nbs_;+a{S+ zQ`2Cq{W8@?vUlLn??#w>prS-|b?Bt^-!hdc+ z;2d~I@jUP2#rz{mX=nLZU$)y~e@CXF;8{Kez*~-)_lr`d(hF zU%8BcQYm3os{qsO4#XG5FP^(TU>m?B1U;k!rr!LqDyjhdE?al)G%oNIlthGua-l3$ zNCW__uJ%=iNrnmj4swnHSa<@2lM+~&){5k)R3%->QYwWAR;5Q$xRR>Qd8c6e6X5-P zg^wg*G9&VLyCW!oD`=|+*Z+i!ivC9YU(RXnM|g_=N{Fe-+8f9T@bF}vQoXzc~` ziJynNRd#oSKz_7|2drdobO=P=oZ1YQy6Zr)%iYK2w;ZBK7;1LKwOot{YFN=(d#rO( z?q!9Cm_g}P(w)qT+{15&24`<2UXm07&Xm@o23s$=aY|PmS7SZFH3?!niY9kw7rFZG z@(;PN5#&qfHE`*=A&I;zejcMZ(}N1qKXq=;myzaTO5??Ri7m#w&&dbXGs*|!i-B~j zJRA$Y{*M8tZzjAgy$3og*H{9MgL3v=9Q&NC-gYv4=uu`xfd-0l0m!qPwsB+@`I}Yl z>0Hs^KEfw~PW#*lar1WMf1xFEH< zwdt~}ELG*Tv9d+;Or14{7LaEaw4>f>^j|x(hPz@Wa^cvHaK|bMZLB7&s`bx8EF9u4|>=8D3F2u?Sxc7Tm_qoUt0d_zc{c zgjWh}8Ef-Q+ZLH=MCR6+uaxQ={bo1C%)cM2lKc8zZyq-$BB7EvInhpsa z6e$ETW)A@}{QhDiZ9E)CjA~A3Et2BPPdYDmVkExK&x%nEj*u*`t~tA}ZZ0YbCtwu} zR|eET^^6uRMs&oy*@H)QX7dPGT9~3Ow|Iw9fGu0d_%FSU4u!F z4H9h^{i#L7rq#IE+Hij3A^V!mIL4Y(*yDqnj;mc753MEYpE{$)80C!=`-si)iZ?@{ zIEw4|oYc*d%H~NqVQ$*9WZl%uRKC>9l<7?;hNV;g(7`9R$CF;n0o%$1loT`4X+H*pm-!qB8ZY-Xic5um1 zKiJv6fwznV$*M!ra*@)UlE-wmdWmI|g)MuE9});d(2*||rNWK-au2}@kOSyElNgbu zeD2&pBUJ0^tyd~^7&yEYwAXPI0nQ^g;_ zoixu{=eje@)LLjz-R_X+4#@?Vm&=itc20FMXtRAE>QwAyIh9y3XAGV4&Q5=)*Fe{^ z`PdFxcbic|n#eMapO%}&Ivi@%+GZLnH;K{0VIGy!73#T1ehAsFpwE|v`?2PK6zAkz zjHK)p+Ha%Ju7X@sZMJsJ(#ER=kBE1&AWxgv2z8u^p{UQ=m7KP403>_FQQTPhPUfQV zWzj;Hubtf~J-IWulcvxGdheL{eQ?$c%Ss(lCvAM_X7oqcOsDs6yV*oa-sDFvI^@U} znGqj&8>bso$gczAN@!pY!6{ybP2{+Q5Dy~@_Zc3tD=GJ}R0={=y_r|Wi4AmysvgAN z?Wo73bHrU8+l~2gyCYsKvT0pM3OkS6XUXqb3toA?ZtMpTo>U{W<#;3suJn#SVQ;Ej(o--8ApE|zC+(dWgtWdAU@YfP z9sy3Dj%E6VDMar;Qq5YyKSt-tp9$or$;uTQU!=?l4ol{cf4xuMQwkBbDiEO992{CPCZf}f)kYp2<$`42_sce8kT6K@ zdhDN`@tay+IbqznwyAc-bk3ws5sAFQvP66j`tWB78UGV<)n3aeh=Z*joa`7DXN{uyiv|rPWEeumf4M$pEF`5jnUaCVK^uL6~Z|Kn%qhi&N_eg>5Ab! zvaGGB|D=_+&FCdBIRr8DD{)WX_GIX%FxeHk32>oh>q)To0sO9+AQ+?{1; zruMa-^U1D9eB%!+5hO%sfb|qME%DkPC6TQuXr!`%!4YI>D5-XdLT?T<#hi18ZiDBJ z8Zj(k9r{($qfle{_5yM}kUORg>F)3ZphLIu2Puns)~X zW1N-2V0mJ}+stw+c7?hxPHc3i)QlAE|q8^o`QK>1|JwObOf^OuXM2 z`T!+)!0Df;UvY4~VekjqA3*y+%I}Nn^vB&Y>5KxHg5<*q^OCuJq*nG98h5xP~VTA*lVB(wkoa&3@qu{4qE4kit{5EK}%l@zl<9+Yrv7rD@n6Oa1R~-8=n|aJ*tdkBH^o z_{U@fvgi;DtPT zS8@NHmxNuI*DqOxtLaTU;+d$He@cB%(x^8Q$1CD}=f@5+@fFATi7=nzUq>Z!oMK?q zL)bCDup3mI?%`uvC*9;oVX7}_1+uFbQopENO=9i4jVS~IR^{#QO8{T%9Z>7g+B=?8 zXUAtt&_38WK)-T+7Q0T4v zqL|r3>v6v_HK5F@_cEqiIH^iRuAa8H`o;IkIL;nN=x4@Eh&;hF(Xw)Cv>+PF`dmBR zh!MC*ujbhRqYc1SwzP$0i>Yj0#m)Dn_w})Dg#C1RuBEbK-|K zv(H8pk3(5(SMc)I;1L1*&Ycmd+r&waQ=`1vyJ`Ds)J@@R6zFN%@-r*mlA}cWcGd5aIDTu4otwC4iJhCUr-_}rc-I|^ z>C#Opq@SoQkgz<7<99y7`ys@^Oe7AtI^teH1&{1XQ@|_p9z7GN?)O0w0mY1Nu~_* zjUDN(B1elN-B&9nM>&hsvSpiWkIS5#vS8)cHymD4V74SzQsYgvX-+I?EypGTz{9-4 zR;Q@JhM=TgE{0vJl%=T5p_+xI z$Yel=SekYuYa*9Ldl<=@55kEaM`d@@Q}e$LH^cNgEHN8bsBYS-rpG7w2gw?-%(#!C zkHQN|h|{m3uy@(|TfC4=JJ-&0?0L|dg(~mo?Mbp=H0b_Dx;ZDNS?6}71kV%+0>a<2 zCf7o}Q{lynt7Sozj4U9^ag)H|_BP5wvkE(0>}FcRt&GsT%N)^GKHkrLa${lg51QW2 zSzo8CjSE&Hf|-=8$k!ZKHFI~V$|qf$7cquWFB966r4iaX`|XMEif1!TAeEI(ERvFN zoNBkBT61VqnmMf@UYS%+{7e-@zlOOoN^DlhhqJ@G>tUsdWYxsn_JQl&l`1BTKRy+( zl699|wNVJXyjE z0dEeK6lk)jVz#V{Vn?cWZ^Eyq17v62T#;giE8+>-eeIT|gPU& zYa-LftETEL|19u2!KYG1Ga!oXn=xZWl?Cb+sI;@3s%%WTtJ;+_RSqi{hs<-TPS6OG zgF9A^;#vh}T9oHgvn`R%TQz11O{=3mf#7PP+F0puT1OR>!a$T_TX8!Uo0T&Qr$ps~ zOXn0L11=Eyg|~c+Lj7dfR1M3efD^TOEq+v1w!1Og8r4qxP;|gz6c_t_O7fDahA^l- z?nonbJeqKn*5U1far~s&O4}gMId44~nd>jFlq1bIf^$zMPdH+=B-?P;F2iUL9p0+0QaQQ~S|3B*P|IycXC1$VwJAVZXOlR!jb- zx<`XjPdM?=Efqln%{|7S@J68S;D90wS#h_ObNTDz4C3*4j-Pw zHW&r7aPgS+cK{x(jh!GBr?AQSF^j_muC@!#IfnszIXhbiyV5tY6m)=;p^b=N-|)>g zwyg-6>9at=8i}MUy|{kW&Vy>@v8~H`o?j|H869LnGJS`lzO)Pd^j+fFn-0X#f7Mg& zLWvVfGG@#*)S^PaQRR#eBxVaz=?MW40WtQK6rOm^Y=#*Ab}hH7Rk&meuMVojzF;Q} z3t+&a#>KC@+hM$x@%i<+T{tt5O=bH)OM3M84hR6BcLx-92Ua|gY#JMdUk|qpk4K&k zYdb#Z@9}MjwfyZ(wB6YO*x8d14R?D7oOw-y;7|!dzq3Wzg|w9}xq?5O5q{FFyelZI zLE01gd@{y9Rv6^qFe&gatzH{1sEpnN&eA7E|E6SHp#9v4=OlO^|yUu{ua%euv9ilIWQ_+;i2< z;xJ{4B|jCVCmD-V9W>ygJ{b-ppn-4l?D7OBkizpSXl}(RdNbyt6}b;lY%qD3+n8Fq z_T%3^7Kl8$uD!{H&NzaPA}c0U%M3e+gcF5izTWc zZ(t@zu`~&NO+Dz%PQ)DdL-UuOQ&drIzlJ1ZwQyt=flffw3+e@GJ4Rk{aa03r+Ah0K zCbNKeMc#n!3+?p?rUOvg-VgdR4E0?mod|OWbk`RYwoV*|1Dx*IizTehd~$=V%mCbF z85?!y2H|Od?77ROlDR>f)#$Vpo1N-~W0uv>)j9QMMJ-^<3in!(!>|ti9TPd<@1a7M zW>SkUv)q`0ZgXVIEEju8qG=ZHd~XON_q8Ix`54o{{G0zFiO4T~E@soBJSy#p>3AfR zfKsKM!CbUt+N`T&7T5`vSBQAeq-$GBNKd}%lv2o{mC>wvF7#$h7d5q98Lp=(T~n(X z^0+v%bO5wHP{PY{TGDpwnTx)FnKtK~rbr_>3cJTMh2+7eQ@a-x>CdV&;2&up5=yf( zbUy?&$obSnYlET-_0a*{C~{MzJfzC+|H`Q7njgzAS;Ut;OR^xR>j?EQQ^EbKGquuh zs?IsB9q`9+U~ReDfoynZ(2sT^@R@ZmneffAlT?Z??3|zWT3ndjwDQWml1V!yhuS?J z1#$l4oe|}^X`gQ_?Sg2fDNJ*jpkas)FmIVJm6uk#K%h2Yjn&P&ZAUa!Tk=HmisPSQ zel5thNh5H!$o2ufATe!IPm^Um|FWRX$e_ECU3r|aNpHxfm)K%gVNz_~pDe9wHM<9h zqhFO*EGWa?Fn`(Podo>lk{N)VswjKC?PgV|jZzG_u$759>ZwBlHPRXSoA?HS z8;9fRvpib#LqvQVaxS^jc()av_pt503hhY@6Lgg}pvq)dO@-NkPSwYua`BG)V9X=N zZ9fG;VR;@n^XFPW&C-!-=L=hOKQ6C#QpPjJAD@lZiot}el^x+*J>!cT@f!R@-fix2 zga~T|371XIIj9_K0p*{|P_!4qiwG9id=6bt#v*t3e%T#_o+Hywyl+UUim&MJd)#l9 zgi(8xrq+zPIg272@&t$Ix%BueZdopX|DL({u$Xo{e`hQ%-%TI9|2cCJF*R~ESNPXe zuc4ixxv3MW3k2S<@ZfG^M4*tQCn9V!*9=`f4M-~+nADyJKHL|7`nLr zmq;o_aZ+yZCmP>S6&m%AebBoQ$AaOx;hNEOBv!Ft@W_jToYVN$q~?&n3&Y|X|7xbW zYpr)8v77Nd++;Do{(Q^z1K||p3xiv6HEAdyiLnC9!f9o-VJW7N(h?d%2y#PjZ=1k? z8*Mp(?mN2ufdo9{2vDjW=i5+UV7J|HJ)E=YG7bxyu%}{Bh~-R2%wsOLA#e(v%6K&K zn2G1*#ZhQOm&W8-K;3HKLHoidwBI+z7-Gu z!(0j5Sen|oh#0yUlK$^i|GobIF;~jA@{0y&e25n-c4!cwp~Ax)OYrgBV4@s`&bXZ> z0VeqABl5GY+|r%&RQP!xip(fXIPiI&ilgtzVw$9+7m40ZUOO{AU7vs6H{^k=J){n_CfNf($QaZM#+P8Xs-^RY1l&V3g=H- zHbRTKHm~rR%1t-evja!sV&Mv_Qq08xb6r?3d6$MEpLT#puA~dD8 zmrvaALlt+SqV^_r=i88>tK$_ynSFNR8v6gKt6^8G_)LZ0j2<^s^_kM`u$N0i8 zUsdwTrm`C@ZCLy2%?xD-lpCKG3rmJuHXBPL1yTXU4wP%9M-(UYN2!;36g=72bSNnE zU3_Jhm+3-|kNTID<<yI?gX*D{`9{E+=+6zOS&}kU>B!{tQ?#XLD3Id$ zMV4B+S;I0E%4wq;)_6FwX=t0BYQRIIM6bq2=feFrF{HP=yCc?S)N&A=%8*w}R;m>X^FbfOn zV6#LK!3hDuJSNLgjGHAGgYr?NG*ZRL`Gh|h=w|bXT%2S(qalTO2)vl$@_$G^35`=^ zitx7F#Gf;9IY1nt8ODi_#zlLHz_z_MgAz) zC-w1!lSr@!G1)Ax+%NTnyDnhevX#^y?lG4RV^B#4hHB8?{Wn!+^w4iInNTN>eJ*%BX@!UuI3?LK>v^NWjQTKtv+!ykJD(MoWpz zz^rJd5s+ci5^C(Yn_KzYpQw609(O`1`9}d+m^WXT@)q*p;HLExD%31|TZ{UAy*W2w z`iMZ$BwJAM&>cGPMZiUdSQ4R3p?8erj1_yCp;V!_2yI1jqfWtq{Avc~;E%u~(IlMG z@yt5{Xk`taLDM-*eDQY6EbrWI-drYW(I6hrHj|?mpI!@X+)=(s4D87VI&&6^P5eo; zdE;=jfLYDhqgfUiz`47=KAxVj!eU9cfkgrrLrN6xpzaYayQLNjS<;5}X3JqmrHUyV zoI!dukAX`xv)!7Tbm?t3*5F<+V1OA@r>M zmV~2T&2qH5b+8`~)pz8$?pu_?8&q=(ikv1UtoS3uL>7dm3)v4H9Vtf}DMoCMOstxE znLMTSwh87?!=ZU|XwD^#)-akh!sfQ)Ma-nCUaL%3iQ;e7x3DGOfn)mod@u%n4Ae!q zfts)s0ME`Mebo`Qjg|JaJ%*@I_;brNJa<2tZljk>+jpwlr04fi*og>h-A_*KrSfs* zwN?*tiN2+X1iE&5e-c9)(2}Ql?pyu&is{W{kq9}`Bsji?Jsk3ChaJpXVbH6Oun0jK zg82|bVz)q;GZ1&@eN)Xj3+0wE0A7{g%-28_vG-{iM?t_g6d6i_Gq^<=Q4;yjzfdy^ zfCZ@qxa7!EGX6$L4EamJ2!AmH_0GbW6(rr@d!Q@Q3nlV+1Q#Jc+mm_KkXMKl5RgTZ zwc!CD1F0(o?;T^(`GVM4h`yg!FxMoUxaTleB##Kwck&7#^5VP)dzS#WzKK60`aO^vDE! z;q5Ys)TmYDbR{${aTb#I*kB0r`G&t53i1xe8N&;#B%#+$JEV3G^rt<13bCcoIeuvz zu|{)VQEQ4r;RhGT+vI1M<*V)=aQyRhpCrk5#s5CtE509+|9ra3*qf7npX_Fq=Kubf zeC2uM{?!|6&g00401Yfei3AU>U?2ip$OJ;9Sq#*Xue~Atdu%P`V~;sI5GEoJ)PZui zNjNCj!p!Y>dZzdD!|N}QLnJ;TJG*YQt_^Cog%>{RX}}y}3_VRjmBwL9GndTdD_Mbe zZF40()YAYFAZN}bkuf|uwe1ga!bp#VlbcwAq$oj@5ZfETrIJF`3G!K>5__O;UJJvJ zenP3S53|G!87c=|%v3Oy&M5^fA>)eS!5r*nW=WK~PGxcBVE#^r7nYFbR!0V^>n{dU zs4VYTNG|xI3H@Ru=oS+)!(D~@%{V{3PfdNCsR|q?+we| z={xR}clehHu=D@fw_>9CU_h9VLUvx-I$`W=O$~`KAbJ8RHNtmue~<@>84-`4@6q!9 z3PT}j#h78dxf{6U(|?Bi#pQ+F2**Xl@X1E0hdmiASOLP3!^4ux$_X6q-7DTQvX->I+!eTDA_&hd0&yUcTTqqJxt0y?r9yY3fUu3 z!G@Zy?yV;lu1*wSZrY38jz`KF`)oWPd7~oo+p<$|7I_^O^oT2W^+6S233Vll5Cwg*hE6{}rY1X2qyjL%r~!6yIsOK!Yu1~YPWj+E?n$4Sm|Ry z6JQ%_gPP74u%_`7n=0xCQd;u!D7f(+_@C34K@_Uy_dR{$--qwFX(^Dgy)A>Gor#mZ zr3r(HiLH&L5ySt@nf@z_`uFVRBu@UTaX$2~<)ZYug>{cLO*l*qEGRXx)|N)FP|vT0 zPg3l2iOjL$KONn)V3_>B5Xo|f6|A?C?T)j#oM-;}vrL_=zC-@ftv<>()D%ad!LBw_ zn8_bhuwoBhzR`KFbg%gkKZ6=EVC9o9XGwz$fWms*eLk%>eb-I$j=e82=9XJcN#%-f z-zB(#YI)Uv{5IrE%e~BDCar2=4{|%^2`rdP%x%APrz^1QI8rKEy0^V%JgfB_siqnog(ztd`qDEG8pzD9{v&i;O|@`~hktqG0DF_J73m*$hVw;qU8zadHvX zV36bx&D-ctFESg5j8=^+EXD zwfMSwGqtN)3S3c0LTJuI0oX!Pn@!|8P_I1&YIaLWT91#R)~#G9!} zd_~p3T;GgWy_>%~cil!$3$?xvSU}u|GC!hsHNma{iEl4KKsf_0cvEMmp#=BfIq7O8|1<7om^%oKGX4j|jfc#XcurZzlibHq%|Dx6ekP75-{szE51g ze8A^?pPBPfBRC&MpWfd65APp(EVrZ}GZ&VG-66G~Xckq-ac>C-_3`GOK{RxvhEU^`f#Z*CyG%cN#E91C&s~#q312b~AbNX}S z-HQ$ukd$4>P|l*0M`1PYEHXWl1@&_vx76%w9RC#Ah;WwxMlO@|RbGBv z1TLk7lWuU)nOM8E>&S2THFyih~iNtq2X~=k&)16y_g{!|(<4FC}i}*DP?+BfXGwA24#qBQ1sz zkBzZWBgv2&&9#_2GvVXdII(Z2gxJ$$*Q5L$Qx+w4G|NU&B{BejY5n06*=%;+@Xzkh zF9lDCzjqzs78e~cjx|?eED!-qAdDc_Pa5=(jF=o=tZ|W0TuF;s#GTvI7(C0$#jckN zSg}keci1x-4u@4KOP_2TT9^{0pj%UnG*94X;}f`LDdOKvqJ(3d>RE~uJ_rSs`e$2Q z>BFYWkv@eg^XvHfDu6@24&;{zR55$!>Fi zl^OOyTK7JfuQ=6cpot*8(oXwxozs&=Z1HXfJ&rpaED&ih6-6P)#xg-PX2Gg~Hswac z#k^!!-ZtdvfN~sHh82-{#(_|aEmdM#rM$R>C2jh*uH76K^;VvAqO^-TimGFGprR=| z9RDcdA=oA74V3BWP+2t*?P-`Yus=5RYRKJmfZnRD1Tb9zWbo9)iDNV>@2)ufv8!nivEwnU~p6}xP)a~6@_g?dg$Q}==4GHTw2r$9YP-9OoOk2M$L8h%hKIn zJF{qQsPk6I*MiX`vniv^AB)PJtuo1}B<6Ir<*H7vCDSqW@^(U2$#u*&2WA9Wl?3H` zeAx&HqC20`%eXc|Z^Y~2jvBVZJS7uN?N%GwwCcrU9CeZ;8|F#jQlF{+&ewu8T81Zb z!k1HjoRHIc9OuwhNpPM}T>~^*cp53HCY*{LqOhNW5Rc`KPaC~Ts|)Kn0o?8)X{*G! zoUGs5#qswnpbj^R+zi@QW5vfIvP_{~C1SVCX;vj)sY8a7=sB&8NDK=>^EI$4ykB9t z!`YrDIqR?--@-)3Asm34zt{@+aNJC)OJLnhloky_(J=2Y*4RjQ6!etgk#(0Ks1Dp2 zF{;wTTTVb7IyT>{*{xBExQ{rLCOQ?BvNsJ>1U0Kt7i~8D0NWbMEoCg$3#mEHsw0jC zU|QBFmTXd!M;s@*4#n!s(gM!wg&C?zx!l9y-GLiRVEdR%em6`|VlkPhv+6LL6f-eW z>;8O`rYZ^9!mQK5X@qrOmWl;iSO)80I5%!y(pWD~>Yi#}AY?t_!myHT|G=KJ*Qg8mLMHHJW{IU3gI;7AvQTO!aR&+zPIovlf2hN;M;&fYWfr_)tyq( zTQahuc2d1(=zQ4PQ~wgX(3Mo0XuGVtqTZJ$Pw%L%;v7UPw?3WKRJ!0QJE~YwPCI8# z(+{koXMaCHCW3&as;yKlXlr9MvrO8t;Aj;I|Fh9X>f+-KS88BMqz_GY9!=0xE0TeF zAV-nyP?*|tqJuQ5Lo4bY%y&$)D-U9|?(#=Q5YM}jEexO*hRPAe>l}(cwq#!? z)+uM~sm-U{Cw>Ye0j)W-&as)Mvj1xBWw|!og2;4uAFMH~por^0mCMHI{N|hwnF|dc zwTaHIOC*a@hIcPrXAuEIt?`oAjdo!b2wp$8x6@`f^}4UxJOsc+-TyxYu$bSV=Bbb?>m=i$Ye)jy3SPV zTrbc;Fn?yk3}|U*p%SBRKJH z(9hZB(O}5k=YQ8X%W^&SSqI~`b4~ZI-Te~L-v6M*^?@n=05Q$5{gshXJGioFk4GwV zPthIQP*N(d$`tlzFY`9HhWS`w&K@K{S0_ivF_j+(`v4%bhXa-Xj>6UjNIpl7Shrnr`9QaUzgtDg zH(|GlxaC+}S$h1ZZ0C)ioG(&l3Yvcm{%#E*)oiZu9B7+%Ky**wANyf>C%aMedU?j} zaEq4#)*CZcDv#v?My$&=c4K{<+Famq+6N?TUJDt{0~;wSHx4yV&I>k(!CG4}HhZvp zk_h zop&&Ydgh&aL-ZaUEgmgOO)oXaX&s8C&_#6jnBb2f!->}9I~{6sheT&B!?5@?`to~> zqZ9d6+XwY;uv5kStZ?l7Vg1LPo4x}s$4@+;*P|Mr(;c)PaUAN_*6D+)?dyw__Kv?L3aXAxOhhIK6$WfkD}Rv=5Bk!^2?<>?Pl@f}bh6Iudy z|EdTo#&BmbUo%jalt;fcAorv?jK)3pgtNjl9f~EJUCG7AGeLHavtC36vPVSfoJ&@Y zLbLNjVdsmKHeIx6ums9j%EuX?E`{Sf&>2 za)N3zSgTc!Q=I6)6z?bqzp+XOYn-v)qcecK-mw&uqw$UWHS^d%C<8DjU7>Jhtze9{ zTjgg_$8tLYt5K8&F|G;6QH+>U>ObPTcGT*q*-zsn%Pd~`!X~rVhb3J5u+iW1Vc1@$ z(;P1!six7P)hr2beeR*Vz4p}w)@$-!(lrrG-gArlK4!?kqhTM||0OQ_UPrdzV%HYX zncJN*cnKf+J5-#OFDm8*H~YndY=xdNz!&S}7ELv)mg$Fg%;^VL{}ZszXH1q7ghkiR zA(YE(m01CnHNcM3<-~wIAj#STDx3bGZ-At~)?~&W!L$Fh>$szVx{7_#96vwkF^*dy zZ%GQ*LLjxTJipt;#RV@X{&;Q-dTB{HN9-jcg?vuO-yVB!R3%|k2pIq>wglP;XaDG0oi<2ha1kA}d)vi}pZHCpO-atS+!;XocFR`*obt+Yfkp_jr2WL+=^we|ZsD zgz9O^Q*Ou;SF4lStdeZZ(Qwx*xf;}7mgqehDnIQ_H4xY(yLKtTA6eJR1X+Rb?+_R& z*Cm>UJsOlwxvx^Dkk;m{#l?+Eo!--r?#mt0e9_VGT&dmhN2GKOl$I05IGN&`Id!mg zc62$QC+qg`r>ywy-8a6u-Op<$9~b<&JD|5DJ+S#OAwMH2LhK>K zOt26Pv0@=zmFv_hC?hsL##Ww9JXBH$!iO09bt`+WJ_SEKbyC=kIq4=4L@tAA_!Eu* z4OvM?gb1_q{}=Zk9# zh@fIGY?UjsnW9!(n^G8|6)r0%8Q5}ZviupocIUA}FDq<`+Mcej50nU!c|LtGmD(py z!l%N7YMi0KhQ5b@#}130j!wS1}AI(HQ(}2W`vMBQ(*1|V9LbLGxVJ* z4vKCk*rlG*lhN81yZlU%al=k`=d&8-KDcup$G&1KQy{&_@PVh@Ce;n5=k|(4&iYqB1^R-_2$)5BK)yAPHGP=nrHJ(0A*VJX31}&{h z1pzU{={U{n{wN{dQ1iGmJB5iUVmiAJVk%9EBsn=YV-I3GjC=rO5Ed6N%Y;F{3vaEf=$ZjmougNUr#vMcJiK$XY{Rmvl@)u$sX zY~JcU8GqZIzcv%oj3R^f$mm#A%Cl#C&__`Ed^P%nHEZS0%e!nZFqVIp7|(C$ckGh8 zBp~RcfiU$ zZwss(puJcnj<#y6sr&O~3jy`c93pnTk&q?c$XYZo|8=DXkP<(`9Q-{npn)AgyjD2( zCTW$RSIq@}*G|Kp_Jvjrh6P%!bAa75d&jQOjnZHIpq;|#xOzt0(|Mfd=gShFP4h;WlFQT3gMeF%5DIDC>= zPmm4=z7ju!s;CN+SbLQ%Q`28*-mwBP9E+3K6fI7k-lUjT6AE)W#$-GMIen>_nX)~% zb5pwBryW_QI_*d>W7u?I>zb^!=?O?O&{n`eFdqvRf17+5tiTA`+Ra?P5EH5GNmfM;7QI)_Q%LRXr2Ruu`t#l~!&$!)ZnS|3ukThc8 ziZ!+x?tz-Ss}H9e7cKYZF>4o_6mum*^#z5IE-2>^n^{?eC4`R9i&EVa+v4cisY&(jMMgJhr$y%w>RSxo zS+HSf%^!(5e;@O5@@oWtRf}|M&P3XpvbNS`@P?P^3ab!2RoE~ngIYA>g)PG4s&(3b zJC<6NljLFvx!Eh*mt3kYWZA0YHta^|R5#@=Y7aQ)Nt{Zl)48w4v=LRlN}_7lz9enM zTm9wQWfX{I`u(P$4h|&wxm4CpEW)r?S`xe#SnD(92T)vM{CGJ%;zBTZ(FM_92AM$> zS~`u#9)60N!5V_jbdu$SGb`&vpmz5h;2==19KPtmv$D)fD!^( z8QV8mK>TR7><`a@Se<&z700~ zgCzqAX<>MKe4OIMGRzmq1ljB&(kJr7xVkQyAQq9e)gK7XLz%{aRbk%wIp=wUAjIhCrou1^_`p)-s@0C-xrKuYp7WE0A1eoG|R<<>j0Uqr#wNPX!gn0 zJ>mKySe06CUak*c6`hf?PbAUF2BXTz#yQuvQm%pGRv+sl*k`$|=e%3xJZCG9lQ|={ z)+%SztKMP2A3gbEXYm_R6|gTg$mP+q*dbAEE!B{I$Y_?ZUUwXhqik z67+2s3A&g^bFoQ4-C1h5Ao(|qNVTv6>H5+TvqXub^`TgABv^F*aHK4=GkgA{7R zso^o-4DZSG$LIY4hCg1FSDDdNNK7ewj)(OME|1%7;I1iwa@j+>BvfWM1J*lHibX*& z{Sn8!&sSb}!64C>_Muvg9aO8UOVzy_XlJ8~blDZTA`-eMmny_?TkU*Y0fB>QdulKd z7kW9HLvqy-aW+zhQ1aKowQI|9J%kuBYuFB&qHF?asZ$3AXfZ6Mqd=YPv$_Ejrg7v1 zsy$-4ds-aiqkyupbtcyahQnxKGEFK6A&cu^+4?xQWF(97O>_Ll=NFi6ZiLfZAWSV7 z1l+&`4ll039ME6!A)&82zY@XaR*%8zyU^Zhc-_!AGxJxgm>H@UTgSsj(w4_7saY^? zbWIhM*%6x_twium$~`*tI`sgx326Cu@C$0KjDd`G|p?0o+SZ8!m! zK0ZLa6!L7R$M`pdB5*%XPx^RVZ;<$XK0fjK&&);d5z{>VjDRrIh}^R}EEuI$k0lHo zrced-p%}u4vSkH_+Zhsns#k|*tTND{!BOBj>u+5}1sk?k zmz$~@NeW)V7;KfX<&0&E3R{}8bmch6II-r~Zkq13rn0WpHI#YHBV^p)B;yuOn3l0= z3VD>xWOhoQ#-?Yt^yFSxbfe*nOwSQ&YjXjobjck?*lNj!RRE{Um`P{HAYJsqQ^R>U zXpvL_SCe`YlKU%WcB@M}C4Z$(+O{*ZiQBO4+KQiEq-ZakeA>P`>ZghIQE~r8&Dp99 z*p7mG&U(Wd3+~2o=)}Zf2NiR2ZObJ{sMnUZRS3pgeonItc_z1`M@@6@49-1w#$>sd zGR7FyMa}f@+hk+;$z!rwEmYzSBrt1LmcUsxXO{zgnOZj{{QI9C$vqoz@wGTm0Q>Vp z(z?vTaa#FJ-M>a6rArJiLP~9C{1@>X1F?|`+G>o!252h`1-t2yc$W!395Wo+Pn#>} z^NTl@z73(AwT$Ek7 z^AJkEN|N~OqTwc(1s^LBu<*T5__wXZYCQ#9eHgldoAj05mle`Bns)rj&WE5<-y1?G zc6CbpVn*;)Ar5XmrM?xiRY)?cs+gN)`;)S5?dbV?V1l~6S`7BUSB1k)cv2_1SKD^! z9M;L#K+ja4y>yIF?nOnu5L{*+1Oe3C)!Z{22OUIMIiDGuO1`LR0@*}(@XV-AP0=8F zIh>~}!a-9-&fitA8-n0KRqT}-^hn3Nlt>g+94VW}tONt)C71#&^BQ=5 zGJ3IJ{jSlY&ahS>AZBDI>Z~&7G(L| z8Qa@ec#WLd`pd2b5AN4*2Ebq~(hMl`jU0o03Le5C_~S6jg8@nq#k_|^p!D~?FM-(H zK-|<>;38IO8wRnTg=zdNDnBUs5eaw%_ThkUei!lR`ULG#r$01;W04Ny4AF}JdR9ne zkN$KsuS*I2{I`(v^`^({qi-2C>AN@y{AW2;;2#qC|CYuD9W9K_O#a7P>}p|TB4lo0 zYi1&C;pA*$`+w!uI5{a0M#SNrCYP0(YT7{e#h^f4Sof+F39CFZ@$9+zWn+sbz4 zj{S|~%`sGzflG)*w=WYhJ2Q3cUW18P`?rbrGRTEa5!!AuxwH}20(ks8^_l_A;7Y$o z8OUJ#{WAneA6Wl9h-@IA{NkxcFDOy zFq<%gG-mGZP&Fxg-+sU8MQ$tM;vo+P$J^~$PREnkUQS*vaQZO5SZul-X@eC}CS8uV z>;2e5jCkWiHw-Kt`S@VkBBB!%;ROmb5dG|o@%bEck|pbx$Zw5B57dLQPHbK8L2~Yh zPA>Ra#iAAWTdKMJe2ek=X*ZIjvVg(>2424K@`l+Z!npU%(MulM^>kiv26uoYloRGX z36k=i+4hYe(crQOxzZ;9N~k{??P_W=(W?YNhAPJNd?#)~T$r$RLlx~JxQG%D?{c^s z6XvqWgSzKVu~!`izO8^`wqaPt_i_dovx_wNMuR`-nBbVmN44E}F;$v*^@jmfV&BfS z4DROZU`Er&Gg(GS@Ou`f5uji?N0l#~yQ|nDb^<&s`95e@_XOat^F7Xkz7hfMe*=#D zGn}Q~&#WB4;}r{5!Pxc7hN?v--sI1fo_;C?4@EoH#(J0$-l63-o^6HdFJ3V^!-xVI zl(-}&@* z_hmQJFX$^+Zt>nnqi&I7ee#fdr#<(c`($J1`l|_AA1E2ocrrBKNLHXXFoePN0G3}@ z{`bLnqXE}+Nd7L5nVxcen3S$!z1r{umF`|vze*I&O2xY9@Z*S1>E*g=3#mI8LCKbHddyr89A1qr=_*4 zx8!CJO1ZVBx=J)ru_bqEyT11KX2CtwM9<;IEFuwQPmB?!d&=zmugzmiC|$-&svAPA zD7$$Td<^v_V;aDus#H`7yBW5;M&Y?WX#R?dpl5E~v5&D5;1Lyz!j`17n7ULpXuwu=|t zE;yVktJO!k2vd%+VLGxDdwWt`l)_Nmtx|WJ$K=kS$*zuPG{}mhukvC=KCrOQMEv6m z7rXmi3Oi@OBy>3j&O$s-Zcu=P)t$m>HAvU?orSlm;vik5Qk#*t@z+#`l$17eg#kT> z`2^ig2HDD7T^tPR467v3q3?9)c*)*TYRJzKu>Qa^byESMEGI1juo%MMtvY~B$oI*8 zFlIf%-=3eH2BC!)YNFoqq1e3XVoC^ij}~>7-Yo+6j!ShDI4O(tRKBhc3_h!_3#lr2 zF^-2o1ztWKGo|l7*!LD$O2Qnszb@eUH22YhUr_t(!`tu+J}_zmwgeY=#i@rFaZ8vf zoNE*Q0_8kh#T7`1XKwr?zy4{Dz~pA`IkX3vt+^GA8wNd5|RqZs;(IPxC84-&X16kndW|3@?j>FUqM z&!Z#;;b&Kt>iGI9ef53^Q9t;;@Uqk3GRwtU%Saw~70vDn;ojWd?KRbTU;>5DxbjlD z<)bD1V>BFD*T+*M(2X&Oy80^;sCXVHhh0t5ZjD>b`V^35M~0lf~<=BX-_~1W-+o{hes7aiy82@F?yM>V?^xh7yO3=&7ei3 zqFzmOul5FV@aTCfq!SffRb8)EHNw7?ZA&W2TrYf>*cwmz(!o z!V{v5p=*Km7+Ur@8c?kxfAv1`sGLON>Ngwf&WLdNW-xCRZZ(%hm2AOBb%q8V&<(6SsQ%hP!$OSr8!nK~Q76l{RB3MRJZRiV#ycFRqV%{oY zbw6UbP+FhRiDr+hFsV))%|h?&&i5~@Wr2j*q$G~#LCmE?O%XRSP4>-^|LHnsi`T5U z_v`hZ9%$uupbzTX+imePBm}p_K?<+}&ZMrKx?!oIXy=x6Rik;0W&AD*_={UnhLtwd z;OMTe54LmuA}gi?5DGoemGR_N3yyuj;c05cJjSkl#j%AG1T_bRB zimTtS36-#OL3{!?aG5;|_uo^-J#p-{Vjb+e`<6u8@$b+7Vn5sLMY0K*cmv~0&eT`G zNQqivS1MvwyB20CHS3@w5LD#u#%N7-@pc(B}Qk*e-fq*Bg8o7y}3LBzID3ejyj zeL|_>v^>$DHv*j3RXeZRWuveibqvCk%{BoUbs_g;6|L$#$Pg7diNTjhj)H~a;*%~S zn1cyY_0HkgB9V#q$nk<(-9@HXqk?x`Tia19QMDD19g-t&6*G+Lnweo)gpU=Hm<rP4RTu8IhC(W; zuLM-R5Z&vZGJLT2MnLCNv>M}d=DR}(XobeSW#C!ag`BZ7U-8r`w{&W}!2qSy#IKBxO zaSXdGp-(wKPR_y{fmx=+7vh3J3-y#FE%2|3h_1N07oummq*__coqg#< z-X)+PtU&n)B$&kdQVWDbFli);WHH7>bCF(WNGWSY+87E>MV%@1D%>(cv2f$%`5#q(C~T5jcK{jFZRwVJqN#77HYy(8_kj~*Fe^$ zWZZ>+=Xjv^uUB19?m`9T8&O?;BP#m;eAWK}n@I!>oSjV^J^qasQ`PzgRG7X!S!}@} zDZjLUl><}eQGyfAaX_UkiqnAEnxT~)GQDju*dW+4TPRYuIS(k97`9JOLpa7cl8nNz z+7AcxhR|NTJEfpll+C!_eZ!tT;%459zj%CJAp3N+WAm{9hUWeCD%x6p`og5`eb!za)VbdmHd(w3u8Rh;wu zY-}8!Z`!8z-VDUQN3+;XW0aQ|tCMI;kG#*5_BiJ0O27uz0zM~yGKLUeuN6mUC8zXx zA{b!}pDo(Y5su5Qn436-l7bOIOck8rB15`5$HD}IOM2w68BZ4QD9}nvdQGM&d)`S$ z_&W@;Ep79|ax$BStoMv5q$eI%HGw_s87i!Uq$re&Q#qSC77LD39A71*%N^Z=O=L;q zB{!PNwsZ%Ygu^Y&dvGR~#7@7B8RHSV84OXp-?1cJDaxu+ksoGB`sZYFPcQ#$jI}{8 zU7%7Bc(h?v(`^tU?bPQ8y64nzolK~x;X4yyGURAa_l&Wp-!wNwQ&H4oG-d=+X))RM zFk5xS!BHk>0ZS6mjWh`G9}`i>sxV)@aU`x4NRR}soErC6n3R}E@M!fgW08jX^>eLW zu=%-d4C2@grm3ck43Rgj(c5;Yp|~PDQK*FKkENVhYNQSTXSKqJx#iS+7#(hb%T)iI zBMyd(jVQl=Ug1}~!3D)$T`Uvbd0qdu_20Pj#H<|ZV;19CH@>r^1e3v<(ZeUn`CUCm z3r}N2b8myI!$XgBCv_S;_zG-Omq`O%02#?>e1v3bvH4Q!W!k8l+j(0seYa7o-)DVj zEhyI)yA9a-`Dv^&EkIe?JAe7baso_KSBNrTirQIRf2$#0e?dLYVX#6n73J_ZTcs;` z=^_Q}N^XJ4=ikx))by&Puc_DC21MRGUU)ezQcDdPhpB<;hCYin z)JBR_E@Ua`!X?_CkPpiWWq72uf6qF6GcJ@_lnhaZvif4VLN9vpv8R%@ELJt>*U@w@ zhOD$Aw>}un_u{0(g^y0{nErZTAss$L!|4rDX#+Ah&+tL*^6}1>Rs-O7vi35qZaSiNjYud3XI!VB!&#p#>|5 z4fsnU8rh0tkLC43AfAS#Gt8F*(e{aNw_x=<&F&6fjZj6hciZxj?lD;F^&p%_L|Ld7 zamHM6VHOb6x$=A29x>_0#(U&gBz23o*EcXm?o@^0eUMMjlR_mGq(4(CI;xF4&XNQ1 z*3c5viF=>eixMQs09lQu3%F-LFq1c=ICucFPhne(v2ExpyN0ihuE!S%XSCEkk@^6J zt}%Hm&FUCppvIB)u;UGAgYrO|HSyjCxA~cJ4)ZU@zrL&LpQfz#-%B7=(SU&1{`0%~ z|D7-V2RTu#4&|(Tg!Xxzc_c#?XWTFVvPhwYoFJV*WC@G}t566f58e++N6#F0xM!X2 z0WwFoyjfX+kvx-G=Qz#t5l`bZ0uGI_B1kqY$^ORzJ1^nMKs2FT^gnDR9$UTHhaaW*hWxg#?vSWX>wJSrHvz* zDeK5pP+=519LJT+C$eY*zia7J0~Z@MKse38qt`SUkt{jY4JiB;V!0%l+3nn@S1aw2$0oQ@S4Ca z($?*S)*~!doS^92V5*x9+>mBV_$)30gBaRXS|avtWJH&dir?EbSWqt5m8fGrDQc6x zlaxfAlj9qV`C3j}AZT4(FF-@qG3cw59GG^fM2>sZ??eE9Rl2{*Pe`A0yf?l8h+)VI zDDZ^!W)@7zkeO!>icW=TNiixqoyUDs4JuODPx_;{;q{d6dLjWf)|n44;E_lVJO+x# z{+zg%te3ZWM*41>;heG5eI*t!W)ZHrChg$Z*mbO5VQ&S@569INFnHH?_Md|c(hilZR0P>6lZ^cozfs-d}2|yTC zO2ul)zCdZ#=IAS%{<_W}XJ?#n0B|O2;-=miG>#7|K;bBwei_wVM%`RrQ>s-^- z4|$7_DGs{*ZRfg%&@=S1ILQ%)(!ynv8w)m>l!LP2%w0%fAO>$=`!mr)87b-$I0P`5 zCU9D7so1qmjW7WyqP2cz60bO?;8*;p-Jcy*5#_z4urjoW&n zOmt%KSh02cE3tJ4FR*E<$UzYl4HWhfuvLkQ44VBHz#gDDWcsLnnEfo;mw~g}Gl8Q< z+{gB;BXu^7%6brkvpc|hY6#_zfZ860hw~fp%CebrmN9B7VN_(M=8YPpsFS-gk%hK$ z*Gu{UBB*!~AJn_85AEHz3qB4)3t?~~B5covr6hFJ-S5KdFr%}dtKMaJ^t z8HJJobES=aa63+L4xv2HjOPjT0D}|^(k34fmyIg@UU7CG_BcvhX)oM_+<$H2QK_xQ zu|Lt4<$_%$X}t4pplh?0-}O_OdJ9czT^rF%COmCn3UZ>Fxv{uCouRtr+TY5s-JntC z;0g?vA#~r!$V=z0_=9Vc)4WLVSbpSCaNYOki4VpsCNDr&#l}r1-)a=cR5$81$L&5; zwuf?ltZaCJGvwU2|9y5M5L4kKJZA>Ny)((JHeNbIF%1W1n^aF8=CGt0#mlIvp~e4! z>QfezYtc-cuk@OkY_w>YvKS3)oCzWu$$&I2y^P00uAX&kV96}zME%kCF2OCLJP4fM zICNOgp>$)1!ASMQ2luTV0=N`dPxpw2pAjX-wRli;KHAE zbjPnn`ktOu`QtjQ=~Yu-A`~uYT(w(t=g}0COXCsz)?}xZ(q~I@g$WoZvQys0+b~UL zKa7t2(dv;M@q3SwU3FW)(bPuU-O@?)E7RfjVB0}`_(_z9E84T|b?J&7*wW0DV0kV# zup0XHTPO|BV~mFTjcFGQIYSmSyVtP(Rv`$ctM6JLrhCS;@u;su6iI{A6E9L zSpqXxM6qJl+!&8zekNeah?<QhQoxq!k+VcK<8|kGsdARHvu8f$%R>`GS%QN@-QCzRc zt9$<4r8bCuxts5dmCPP7v8);GV;$jlJXfTtf>5! zs$y-W7Nf=sZ^*e#+PqpXSY(ZAa>r^=N7Y3SO^Ksg)-cO;6HSQ|J7v^h8}<0U{q=_i z?jGdniG&0?>*R1DgsA=DdDLc;$MbGaxJQf++1g?rj|U*Q8;PG0LROLxrG1AI=Y+p9zi zNbRhbd-gUU^MP^?4(o4NI3H;;Um?&{!B|t6DTQz&8b7VP zq0wIgS^N3z0v4 zLW~!_Rb7Ab>cIm4EDn;i2nBWhksnwt8^*2@p`*;fykE3We%^ z3Z6Ciu6;Y_!r*?GGP#zS%5#3=b2wy;ZPK}?ghaR=Vu^N4r(B`l3Xw{bXXuXvAB)s= z-UrMt>k#+hq0?Cx{(K|6M2DMiX#a_?x|j%ncu^KkhH#*(ci@hqG$~waw&#LK@Om&^ zl;rPru{cOf<7st(iFF*%bW){UbYBsHw%NX?+G52w6{|PMU(8o87i*snwk*aHqpB`b zi52CoWv9M60IR@NvZu?O9#&*=$f$9Vl)G@Bvvko;VU8uJ`Yq_>%4NbF`jR#Yw<)Ee zr0vF4ENOf@9@#L;DK=pMa7RYg2GtmzMyA_bhaycT zXu9&7qSW*|VoedoHd3}5s$O))SFYvY#Yn5^o_Ih$`p{|Q;+#9BriNY%31xDrskuD= zHFv(NJp)JO45_iW&*8ptwS_@yia3BBr@W;sEfCa*%5x-JPiBZ!lde()H=wv+%o9dZ z+83vS68nxGd9aLqBqYxWvgV86qj%P2{cTvRUhIGZ@1VRE3l;j?;k+jfujdEO8YCKS z^&m%_$r(O-(q#h9&&y55>#bRptlE8Qs-6xQF-OcR;R)FhzbS_Z96bQo3pgEaCwb4< zmDcnrk!ucUP$p>?FzxRz@#D{u+Yii?r}7}_zp<;sx2(qbpE0bGhpmy4 ziR1t1kR%fS^XEI}$js5i$?2ae+dp8ePaLDvFav6cT$7D=^SM%PWulM{?CI&tFA551 zG>BFfDAJi8;e?(*zo~wL-8ke#8m@4xKMuCj?d;F@7k@$eNYPOtP{lyo3EFX>8ag=r zpl=I(NLOnLDVp^hi5K&Xsd!P8$F^-d#IE%qWzRRzayf9?&NR5WY~C)?inL`Ju{@hx z4}v@9gb?qarovU*C16~Tf=~gVY6CS`?DHD5=}8v?ZkMTdRT2CBYul7JH&t_mE}&7p z6Dmg0juQc1wccqj_ucQd#lou(4_^*oKl;)4#~SO{n0_*d#f#S`atNij;p_zUNv6PK@VpWK^N2SHE=4<7S{hbx#a)Luk@I9 z%1cUGP}DC6$|y?m$p20XvN306wTX8`RB%YNUJo&5w`D7P_B`s{9nrcanhY;|{sj99 zV&3K|{AO1yEF~$|)BnQX+fJbCDKc6BXm&u>Du2wdz0{sBYTfk(Y~UjPkyXns@b*B zx??5AF*|9HwA66X4!frKTE}o`qd^l`sW@_B6wCG7@n>11X|q`EXnSWhqiwxnS>iR= zczzGuV8D_}Tbv@7dX0q;opp4GYT!%Sw%$cu?b2%5ai8vMs2^!6*c{n61mD0bX zLRE^$9oU8|&mab3+ND%m{or^t=aXh<4YIvqYe;3N6^rzY^SksgyG+Pz13Os58LyM> zWlDkVYG(T{Rg>v`HRBzIIbbne`D**ErZsXZ0h+Dsz#)$}*oKDOnG@cMYuHvEwdQ?% z`zE;3E7i$7=!nl_?&?DqTv5T@Q<(F@RX1nvKsi#r1%tdv;Ir>+P=oU_10_SLun;e< zYhpr$l{p|I?k1e*hC_;}l34oPbMt^|me+ZtGv;R99y8Ulk}n)OA-|q4%&XY-N7|KF zrnIS{7E_1r6)oVwPEk1%6(bmDh%kUIj~jEkaQ- zCVukMYcwgkX?9CKHl}ywQPmLG2F#yvgZY8aW6A17*SEn~{}%5kYfFg16^|>1@!}pL zfxl}N`YZBtk;NWwj~1X~A7`5Ti|3dym{{A0?~X&X{X76D)-c)&R|2;XsrUm2+#Q0B zU*wbxF8Ds)xF|@RFC+TX|5UgoXf=2(U=ol}zXlI(OKRj%F1#=CO2Lnq1@4=#1lO2W zSk9Ss|6GS~fEfT(oA8c3C+EN$04uBLx*p~l@nJAO0>vt63ryLyf1y1B`na1*HiqxPnW@Cn|VZ~q*4jStBDaDjj4 ze2w)Aem{JXn_IdqUVIV4Ke!1v zl@LiIWfdLY;1IINAYE+ixsWhDhy5XpdC$!;gDd+!4E;AGO<4D1Z1PeRAK~#gfH4k6 zSuw5m%20O7*?yNufWR+Y*p+OlzaKu-_O;E=uM6e|tH$ZUE#;Z>_)8n1wrG)UTN%L) z8bgEriOEQ56r?L-fEsKr_5~8bnizH&F{)(Q?XI1k=H*5f$qni2-v&Iu?lsI0v4McD zzL^Z7|6B+Jj19j7yY2sv0cA-O%3FD9iT`Bk&$hI#AXD1!A4xxg)*{Bwfl^2XKnMkp zjg_27yGV!=(>a(4!!>QJ>J_P%G^-b>G^y7uVJHZbEH$<2JGY*f=v*9qy*!|EspFXFzsc1iwd-O$ zZNfN759P7Kb)h5eLZ|Ac?;%it3s)|EBmL~FU^=7D36}1kJL4cTNp30p#v?|+Zj`a; z>~1^P%>5tlo>eKW7aMT;->&)nQAIjcPTi>`_CXMdsi)o%sU?4mv*{lVXT}8c74W5w z>ZQwA&bl^tO@p*xbq#J!balt7!CSGq#&;*+bZJ4XoOLVjx#)FBs;Zy=pm`UEvEQo9 zcWUAjzP2dfUxA0gwHRxJ^=IS5x?thPKVb*4OW?4!X|1P8M@rQK23H&f(Q*ncbuMSFpNR<7!WEV0#o8ygIu!jc=#Gdxa1^OZ#u;U(xA9gb8nsZMb%* zvbx3aTgQw#tK4fJY}sFYz;_7WuNYqb>h>hs*~kCQ)!V*idI8bx$>cY`yJtWMaOMheBds7Hw|7+v%`x;znCl1zM$Q z&f`(}UuECoQH?b93>?##6rW?=nnkzTHYmaj?gb@F&rFLHZLi~)w;r>qmY|F7J74J# z?xM&nuT`RN)UFQ2*VPy=EYl765UdU=J4^OVG|Y`Ugm5c4(wId$Y29us52maw37xh}@0G&DAw~0Y5rHzY=5p!;DJ;RNimRy*|6N@MEDcw=yut=~ z64hncKjGxHlH{DikPc8)TY6TLv|+2+7hJKZMhI@s!GR|M?FF54coHCTN0%@DX=C18 zVah0|9MIL9LzHu(@-F5}I$V{Uc6!kCd+3S^P#sR_=4nVqwFzq;9ZXBBWu78M}-GU86e(lY~ODRtF8QygPlRVsAKd{c36dnfb9?>o|?vOHi-1#x{NVcLPYg- za3bSk8Yv?;s�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≪hh!SP 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%0Aa$yvH)}2X* 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%0DyFwz2R3Fv zcRqj~gpa8h#OF{fo`vzHWn09pog;O+zoA3n%~G4E3a`|iwm3^du!{HT==+w>u@5OA zp-G)6utH{v7EF>P8OybHSQ(UGy>0rlhQ-o&xvHL>TClAuyTEu5NZeG zowZd`JXp{Vs|Y8KjmKa{X=KT_sBO4*`(lpxE{4K>ErMr)byhfht>MV~Y9obml{36| zTtmyIwZ{4-A^=}jyf`PE+zBOOy||!!+qZwVJb2B0I2m36^t->6u|oOk<)E0@rQyVA zn2`1$>O*}#P`TN7cYfw%vdu_ + + 4.0.0 + + org.eclipse.andmore.ddmsuilib + eclipse-plugin + ddmsuilib + + + ../../pom.xml + org.eclipse.andmore + andmore-core-parent + 0.5.0-SNAPSHOT + + + + + org.eclipse.tycho + tycho-source-plugin + ${tycho-version} + + + plugin-source + + plugin-source + + + + + + + + diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/Addr2Line.java b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/Addr2Line.java new file mode 100644 index 00000000..10799ec0 --- /dev/null +++ b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/Addr2Line.java @@ -0,0 +1,355 @@ +/* + * Copyright (C) 2007 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.ddmuilib; + +import com.android.ddmlib.Log; +import com.android.ddmlib.NativeLibraryMapInfo; +import com.android.ddmlib.NativeStackCallInfo; + +import java.io.BufferedOutputStream; +import java.io.BufferedReader; +import java.io.File; +import java.io.IOException; +import java.io.InputStreamReader; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; + +/** + * Represents an addr2line process to get filename/method information from a + * memory address.
+ * Each process can only handle one library, which should be provided when + * creating a new process.
+ *
+ * The processes take some time to load as they need to parse the library files. + * For this reason, processes cannot be manually started. Instead the class + * keeps an internal list of processes and one asks for a process for a specific + * library, using getProcess(String library).

+ * Internally, the processes are started in pipe mode to be able to query them + * with multiple addresses. + */ +public class Addr2Line { + private static final String ANDROID_SYMBOLS_ENVVAR = "ANDROID_SYMBOLS"; + + private static final String LIBRARY_NOT_FOUND_MESSAGE_FORMAT = + "Unable to locate library %s on disk. Addresses mapping to this library " + + "will not be resolved. In order to fix this, set the the library search path " + + "in the UI, or set the environment variable " + ANDROID_SYMBOLS_ENVVAR + "."; + + /** + * Loaded processes list. This is also used as a locking object for any + * methods dealing with starting/stopping/creating processes/querying for + * method. + */ + private static final HashMap sProcessCache = + new HashMap(); + + /** + * byte array representing a carriage return. Used to push addresses in the + * process pipes. + */ + private static final byte[] sCrLf = { + '\n' + }; + + /** Path to the library */ + private NativeLibraryMapInfo mLibrary; + + /** the command line process */ + private Process mProcess; + + /** buffer to read the result of the command line process from */ + private BufferedReader mResultReader; + + /** + * output stream to provide new addresses to decode to the command line + * process + */ + private BufferedOutputStream mAddressWriter; + + private static final String DEFAULT_LIBRARY_SYMBOLS_FOLDER; + static { + String symbols = System.getenv(ANDROID_SYMBOLS_ENVVAR); + if (symbols == null) { + DEFAULT_LIBRARY_SYMBOLS_FOLDER = DdmUiPreferences.getSymbolDirectory(); + } else { + DEFAULT_LIBRARY_SYMBOLS_FOLDER = symbols; + } + } + + private static List mLibrarySearchPaths = new ArrayList(); + + /** + * Set the search path where libraries should be found. + * @param path search path to use, can be a colon separated list of paths if multiple folders + * should be searched + */ + public static void setSearchPath(String path) { + mLibrarySearchPaths.clear(); + mLibrarySearchPaths.addAll(Arrays.asList(path.split(":"))); + } + + /** + * Returns the instance of a Addr2Line process for the specified library. + *
The library should be in a format that makes
+ * $ANDROID_PRODUCT_OUT + "/symbols" + library a valid file. + * + * @param library the library in which to look for addresses. + * @return a new Addr2Line object representing a started process, ready to + * be queried for addresses. If any error happened when launching a + * new process, null will be returned. + */ + public static Addr2Line getProcess(final NativeLibraryMapInfo library) { + String libName = library.getLibraryName(); + + // synchronize around the hashmap object + if (libName != null) { + synchronized (sProcessCache) { + // look for an existing process + Addr2Line process = sProcessCache.get(libName); + + // if we don't find one, we create it + if (process == null) { + process = new Addr2Line(library); + + // then we start it + boolean status = process.start(); + + if (status) { + // if starting the process worked, then we add it to the + // list. + sProcessCache.put(libName, process); + } else { + // otherwise we just drop the object, to return null + process = null; + } + } + // return the process + return process; + } + } + return null; + } + + /** + * Construct the object with a library name. The library should be present + * in the search path as provided by ANDROID_SYMBOLS, ANDROID_OUT/symbols, or in the user + * provided search path. + * + * @param library the library in which to look for address. + */ + private Addr2Line(final NativeLibraryMapInfo library) { + mLibrary = library; + } + + /** + * Search for the library in the library search path and obtain the full path to where it + * is found. + * @return fully resolved path to the library if found in search path, null otherwise + */ + private String getLibraryPath(String library) { + // first check the symbols folder + String path = DEFAULT_LIBRARY_SYMBOLS_FOLDER + library; + if (new File(path).exists()) { + return path; + } + + for (String p : mLibrarySearchPaths) { + // try appending the full path on device + String fullPath = p + "/" + library; + if (new File(fullPath).exists()) { + return fullPath; + } + + // try appending basename(library) + fullPath = p + "/" + new File(library).getName(); + if (new File(fullPath).exists()) { + return fullPath; + } + } + + return null; + } + + /** + * Starts the command line process. + * + * @return true if the process was started, false if it failed to start, or + * if there was any other errors. + */ + private boolean start() { + // because this is only called from getProcess() we know we don't need + // to synchronize this code. + + String addr2Line = System.getenv("ANDROID_ADDR2LINE"); + if (addr2Line == null) { + addr2Line = DdmUiPreferences.getAddr2Line(); + } + + // build the command line + String[] command = new String[5]; + command[0] = addr2Line; + command[1] = "-C"; + command[2] = "-f"; + command[3] = "-e"; + + String fullPath = getLibraryPath(mLibrary.getLibraryName()); + if (fullPath == null) { + String msg = String.format(LIBRARY_NOT_FOUND_MESSAGE_FORMAT, mLibrary.getLibraryName()); + Log.e("ddm-Addr2Line", msg); + return false; + } + + command[4] = fullPath; + + try { + // attempt to start the process + mProcess = Runtime.getRuntime().exec(command); + + if (mProcess != null) { + // get the result reader + InputStreamReader is = new InputStreamReader(mProcess + .getInputStream()); + mResultReader = new BufferedReader(is); + + // get the outstream to write the addresses + mAddressWriter = new BufferedOutputStream(mProcess + .getOutputStream()); + + // check our streams are here + if (mResultReader == null || mAddressWriter == null) { + // not here? stop the process and return false; + mProcess.destroy(); + mProcess = null; + return false; + } + + // return a success + return true; + } + + } catch (IOException e) { + // log the error + String msg = String.format( + "Error while trying to start %1$s process for library %2$s", + DdmUiPreferences.getAddr2Line(), mLibrary); + Log.e("ddm-Addr2Line", msg); + + // drop the process just in case + if (mProcess != null) { + mProcess.destroy(); + mProcess = null; + } + } + + // we can be here either cause the allocation of mProcess failed, or we + // caught an exception + return false; + } + + /** + * Stops the command line process. + */ + public void stop() { + synchronized (sProcessCache) { + if (mProcess != null) { + // remove the process from the list + sProcessCache.remove(mLibrary); + + // then stops the process + mProcess.destroy(); + + // set the reference to null. + // this allows to make sure another thread calling getAddress() + // will not query a stopped thread + mProcess = null; + } + } + } + + /** + * Stops all current running processes. + */ + public static void stopAll() { + // because of concurrent access (and our use of HashMap.values()), we + // can't rely on the synchronized inside stop(). We need to put one + // around the whole loop. + synchronized (sProcessCache) { + // just a basic loop on all the values in the hashmap and call to + // stop(); + Collection col = sProcessCache.values(); + for (Addr2Line a2l : col) { + a2l.stop(); + } + } + } + + /** + * Looks up an address and returns method name, source file name, and line + * number. + * + * @param addr the address to look up + * @return a BacktraceInfo object containing the method/filename/linenumber + * or null if the process we stopped before the query could be + * processed, or if an IO exception happened. + */ + public NativeStackCallInfo getAddress(long addr) { + long offset = addr - mLibrary.getStartAddress(); + + // even though we don't access the hashmap object, we need to + // synchronized on it to prevent + // another thread from stopping the process we're going to query. + synchronized (sProcessCache) { + // check the process is still alive/allocated + if (mProcess != null) { + // prepare to the write the address to the output buffer. + + // first, conversion to a string containing the hex value. + String tmp = Long.toString(offset, 16); + + try { + // write the address to the buffer + mAddressWriter.write(tmp.getBytes()); + + // add CR-LF + mAddressWriter.write(sCrLf); + + // flush it all. + mAddressWriter.flush(); + + // read the result. We need to read 2 lines + String method = mResultReader.readLine(); + String source = mResultReader.readLine(); + + // make the backtrace object and return it + if (method != null && source != null) { + return new NativeStackCallInfo(addr, mLibrary.getLibraryName(), method, source); + } + } catch (IOException e) { + // log the error + Log.e("ddms", + "Error while trying to get information for addr: " + + tmp + " in library: " + mLibrary); + // we'll return null later + } + } + } + return null; + } +} diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/AllocationPanel.java b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/AllocationPanel.java new file mode 100644 index 00000000..23775e80 --- /dev/null +++ b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/AllocationPanel.java @@ -0,0 +1,662 @@ +/* + * Copyright (C) 2008 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.ddmuilib; + +import com.android.ddmlib.AllocationInfo; +import com.android.ddmlib.AllocationInfo.AllocationSorter; +import com.android.ddmlib.AllocationInfo.SortMode; +import com.android.ddmlib.AndroidDebugBridge.IClientChangeListener; +import com.android.ddmlib.Client; +import com.android.ddmlib.ClientData.AllocationTrackingStatus; + +import org.eclipse.jface.preference.IPreferenceStore; +import org.eclipse.jface.viewers.ILabelProviderListener; +import org.eclipse.jface.viewers.ISelection; +import org.eclipse.jface.viewers.ISelectionChangedListener; +import org.eclipse.jface.viewers.IStructuredContentProvider; +import org.eclipse.jface.viewers.IStructuredSelection; +import org.eclipse.jface.viewers.ITableLabelProvider; +import org.eclipse.jface.viewers.SelectionChangedEvent; +import org.eclipse.jface.viewers.TableViewer; +import org.eclipse.jface.viewers.Viewer; +import org.eclipse.swt.SWT; +import org.eclipse.swt.SWTException; +import org.eclipse.swt.events.ModifyEvent; +import org.eclipse.swt.events.ModifyListener; +import org.eclipse.swt.events.SelectionAdapter; +import org.eclipse.swt.events.SelectionEvent; +import org.eclipse.swt.graphics.Color; +import org.eclipse.swt.graphics.Image; +import org.eclipse.swt.graphics.Rectangle; +import org.eclipse.swt.layout.FormAttachment; +import org.eclipse.swt.layout.FormData; +import org.eclipse.swt.layout.FormLayout; +import org.eclipse.swt.layout.GridData; +import org.eclipse.swt.layout.GridLayout; +import org.eclipse.swt.widgets.Button; +import org.eclipse.swt.widgets.Composite; +import org.eclipse.swt.widgets.Control; +import org.eclipse.swt.widgets.Display; +import org.eclipse.swt.widgets.Event; +import org.eclipse.swt.widgets.Label; +import org.eclipse.swt.widgets.Listener; +import org.eclipse.swt.widgets.Sash; +import org.eclipse.swt.widgets.Table; +import org.eclipse.swt.widgets.TableColumn; +import org.eclipse.swt.widgets.Text; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Locale; + +/** + * Base class for our information panels. + */ +public class AllocationPanel extends TablePanel { + + private final static String PREFS_ALLOC_COL_NUMBER = "allocPanel.Col00"; //$NON-NLS-1$ + private final static String PREFS_ALLOC_COL_SIZE = "allocPanel.Col0"; //$NON-NLS-1$ + private final static String PREFS_ALLOC_COL_CLASS = "allocPanel.Col1"; //$NON-NLS-1$ + private final static String PREFS_ALLOC_COL_THREAD = "allocPanel.Col2"; //$NON-NLS-1$ + private final static String PREFS_ALLOC_COL_TRACE_CLASS = "allocPanel.Col3"; //$NON-NLS-1$ + private final static String PREFS_ALLOC_COL_TRACE_METHOD = "allocPanel.Col4"; //$NON-NLS-1$ + + private final static String PREFS_ALLOC_SASH = "allocPanel.sash"; //$NON-NLS-1$ + + private static final String PREFS_STACK_COL_CLASS = "allocPanel.stack.col0"; //$NON-NLS-1$ + private static final String PREFS_STACK_COL_METHOD = "allocPanel.stack.col1"; //$NON-NLS-1$ + private static final String PREFS_STACK_COL_FILE = "allocPanel.stack.col2"; //$NON-NLS-1$ + private static final String PREFS_STACK_COL_LINE = "allocPanel.stack.col3"; //$NON-NLS-1$ + private static final String PREFS_STACK_COL_NATIVE = "allocPanel.stack.col4"; //$NON-NLS-1$ + + private Composite mAllocationBase; + private Table mAllocationTable; + private TableViewer mAllocationViewer; + + private StackTracePanel mStackTracePanel; + private Table mStackTraceTable; + private Button mEnableButton; + private Button mRequestButton; + private Button mTraceFilterCheck; + + private final AllocationSorter mSorter = new AllocationSorter(); + private TableColumn mSortColumn; + private Image mSortUpImg; + private Image mSortDownImg; + private String mFilterText = null; + + /** + * Content Provider to display the allocations of a client. + * Expected input is a {@link Client} object, elements used in the table are of type + * {@link AllocationInfo}. + */ + private class AllocationContentProvider implements IStructuredContentProvider { + @Override + public Object[] getElements(Object inputElement) { + if (inputElement instanceof Client) { + AllocationInfo[] allocs = ((Client)inputElement).getClientData().getAllocations(); + if (allocs != null) { + if (mFilterText != null && mFilterText.length() > 0) { + allocs = getFilteredAllocations(allocs, mFilterText); + } + Arrays.sort(allocs, mSorter); + return allocs; + } + } + + return new Object[0]; + } + + @Override + public void dispose() { + // pass + } + + @Override + public void inputChanged(Viewer viewer, Object oldInput, Object newInput) { + // pass + } + } + + /** + * A Label Provider to use with {@link AllocationContentProvider}. It expects the elements to be + * of type {@link AllocationInfo}. + */ + private static class AllocationLabelProvider implements ITableLabelProvider { + + @Override + public Image getColumnImage(Object element, int columnIndex) { + return null; + } + + @Override + public String getColumnText(Object element, int columnIndex) { + if (element instanceof AllocationInfo) { + AllocationInfo alloc = (AllocationInfo)element; + switch (columnIndex) { + case 0: + return Integer.toString(alloc.getAllocNumber()); + case 1: + return Integer.toString(alloc.getSize()); + case 2: + return alloc.getAllocatedClass(); + case 3: + return Short.toString(alloc.getThreadId()); + case 4: + return alloc.getFirstTraceClassName(); + case 5: + return alloc.getFirstTraceMethodName(); + } + } + + return null; + } + + @Override + public void addListener(ILabelProviderListener listener) { + // pass + } + + @Override + public void dispose() { + // pass + } + + @Override + public boolean isLabelProperty(Object element, String property) { + // pass + return false; + } + + @Override + public void removeListener(ILabelProviderListener listener) { + // pass + } + } + + /** + * Create our control(s). + */ + @Override + protected Control createControl(Composite parent) { + final IPreferenceStore store = DdmUiPreferences.getStore(); + + Display display = parent.getDisplay(); + + // get some images + mSortUpImg = ImageLoader.getDdmUiLibLoader().loadImage("sort_up.png", display); + mSortDownImg = ImageLoader.getDdmUiLibLoader().loadImage("sort_down.png", display); + + // base composite for selected client with enabled thread update. + mAllocationBase = new Composite(parent, SWT.NONE); + mAllocationBase.setLayout(new FormLayout()); + + // table above the sash + Composite topParent = new Composite(mAllocationBase, SWT.NONE); + topParent.setLayout(new GridLayout(6, false)); + + mEnableButton = new Button(topParent, SWT.PUSH); + mEnableButton.addSelectionListener(new SelectionAdapter() { + @Override + public void widgetSelected(SelectionEvent e) { + Client current = getCurrentClient(); + AllocationTrackingStatus status = current.getClientData().getAllocationStatus(); + if (status == AllocationTrackingStatus.ON) { + current.enableAllocationTracker(false); + } else { + current.enableAllocationTracker(true); + } + current.requestAllocationStatus(); + } + }); + + mRequestButton = new Button(topParent, SWT.PUSH); + mRequestButton.setText("Get Allocations"); + mRequestButton.addSelectionListener(new SelectionAdapter() { + @Override + public void widgetSelected(SelectionEvent e) { + getCurrentClient().requestAllocationDetails(); + } + }); + + setUpButtons(false /* enabled */, AllocationTrackingStatus.OFF); + + GridData gridData; + + Composite spacer = new Composite(topParent, SWT.NONE); + spacer.setLayoutData(gridData = new GridData(GridData.FILL_HORIZONTAL)); + + new Label(topParent, SWT.NONE).setText("Filter:"); + + final Text filterText = new Text(topParent, SWT.BORDER); + filterText.setLayoutData(gridData = new GridData(GridData.FILL_HORIZONTAL)); + gridData.widthHint = 200; + + filterText.addModifyListener(new ModifyListener() { + @Override + public void modifyText(ModifyEvent arg0) { + mFilterText = filterText.getText().trim(); + mAllocationViewer.refresh(); + } + }); + + mTraceFilterCheck = new Button(topParent, SWT.CHECK); + mTraceFilterCheck.setText("Inc. trace"); + mTraceFilterCheck.addSelectionListener(new SelectionAdapter() { + @Override + public void widgetSelected(SelectionEvent arg0) { + mAllocationViewer.refresh(); + } + }); + + mAllocationTable = new Table(topParent, SWT.MULTI | SWT.FULL_SELECTION); + mAllocationTable.setLayoutData(gridData = new GridData(GridData.FILL_BOTH)); + gridData.horizontalSpan = 6; + mAllocationTable.setHeaderVisible(true); + mAllocationTable.setLinesVisible(true); + + final TableColumn numberCol = TableHelper.createTableColumn( + mAllocationTable, + "Alloc Order", + SWT.RIGHT, + "Alloc Order", //$NON-NLS-1$ + PREFS_ALLOC_COL_NUMBER, store); + numberCol.addSelectionListener(new SelectionAdapter() { + @Override + public void widgetSelected(SelectionEvent arg0) { + setSortColumn(numberCol, SortMode.NUMBER); + } + }); + + final TableColumn sizeCol = TableHelper.createTableColumn( + mAllocationTable, + "Allocation Size", + SWT.RIGHT, + "888", //$NON-NLS-1$ + PREFS_ALLOC_COL_SIZE, store); + sizeCol.addSelectionListener(new SelectionAdapter() { + @Override + public void widgetSelected(SelectionEvent arg0) { + setSortColumn(sizeCol, SortMode.SIZE); + } + }); + + final TableColumn classCol = TableHelper.createTableColumn( + mAllocationTable, + "Allocated Class", + SWT.LEFT, + "Allocated Class", //$NON-NLS-1$ + PREFS_ALLOC_COL_CLASS, store); + classCol.addSelectionListener(new SelectionAdapter() { + @Override + public void widgetSelected(SelectionEvent arg0) { + setSortColumn(classCol, SortMode.CLASS); + } + }); + + final TableColumn threadCol = TableHelper.createTableColumn( + mAllocationTable, + "Thread Id", + SWT.LEFT, + "999", //$NON-NLS-1$ + PREFS_ALLOC_COL_THREAD, store); + threadCol.addSelectionListener(new SelectionAdapter() { + @Override + public void widgetSelected(SelectionEvent arg0) { + setSortColumn(threadCol, SortMode.THREAD); + } + }); + + final TableColumn inClassCol = TableHelper.createTableColumn( + mAllocationTable, + "Allocated in", + SWT.LEFT, + "utime", //$NON-NLS-1$ + PREFS_ALLOC_COL_TRACE_CLASS, store); + inClassCol.addSelectionListener(new SelectionAdapter() { + @Override + public void widgetSelected(SelectionEvent arg0) { + setSortColumn(inClassCol, SortMode.IN_CLASS); + } + }); + + final TableColumn inMethodCol = TableHelper.createTableColumn( + mAllocationTable, + "Allocated in", + SWT.LEFT, + "utime", //$NON-NLS-1$ + PREFS_ALLOC_COL_TRACE_METHOD, store); + inMethodCol.addSelectionListener(new SelectionAdapter() { + @Override + public void widgetSelected(SelectionEvent arg0) { + setSortColumn(inMethodCol, SortMode.IN_METHOD); + } + }); + + // init the default sort colum + switch (mSorter.getSortMode()) { + case SIZE: + mSortColumn = sizeCol; + break; + case CLASS: + mSortColumn = classCol; + break; + case THREAD: + mSortColumn = threadCol; + break; + case IN_CLASS: + mSortColumn = inClassCol; + break; + case IN_METHOD: + mSortColumn = inMethodCol; + break; + } + + mSortColumn.setImage(mSorter.isDescending() ? mSortDownImg : mSortUpImg); + + mAllocationViewer = new TableViewer(mAllocationTable); + mAllocationViewer.setContentProvider(new AllocationContentProvider()); + mAllocationViewer.setLabelProvider(new AllocationLabelProvider()); + + mAllocationViewer.addSelectionChangedListener(new ISelectionChangedListener() { + @Override + public void selectionChanged(SelectionChangedEvent event) { + AllocationInfo selectedAlloc = getAllocationSelection(event.getSelection()); + updateAllocationStackTrace(selectedAlloc); + } + }); + + // the separating sash + final Sash sash = new Sash(mAllocationBase, SWT.HORIZONTAL); + Color darkGray = parent.getDisplay().getSystemColor(SWT.COLOR_DARK_GRAY); + sash.setBackground(darkGray); + + // the UI below the sash + mStackTracePanel = new StackTracePanel(); + mStackTraceTable = mStackTracePanel.createPanel(mAllocationBase, + PREFS_STACK_COL_CLASS, + PREFS_STACK_COL_METHOD, + PREFS_STACK_COL_FILE, + PREFS_STACK_COL_LINE, + PREFS_STACK_COL_NATIVE, + store); + + // now setup the sash. + // form layout data + FormData data = new FormData(); + data.top = new FormAttachment(0, 0); + data.bottom = new FormAttachment(sash, 0); + data.left = new FormAttachment(0, 0); + data.right = new FormAttachment(100, 0); + topParent.setLayoutData(data); + + final FormData sashData = new FormData(); + if (store != null && store.contains(PREFS_ALLOC_SASH)) { + sashData.top = new FormAttachment(0, store.getInt(PREFS_ALLOC_SASH)); + } else { + sashData.top = new FormAttachment(50,0); // 50% across + } + sashData.left = new FormAttachment(0, 0); + sashData.right = new FormAttachment(100, 0); + sash.setLayoutData(sashData); + + data = new FormData(); + data.top = new FormAttachment(sash, 0); + data.bottom = new FormAttachment(100, 0); + data.left = new FormAttachment(0, 0); + data.right = new FormAttachment(100, 0); + mStackTraceTable.setLayoutData(data); + + // allow resizes, but cap at minPanelWidth + sash.addListener(SWT.Selection, new Listener() { + @Override + public void handleEvent(Event e) { + Rectangle sashRect = sash.getBounds(); + Rectangle panelRect = mAllocationBase.getClientArea(); + int bottom = panelRect.height - sashRect.height - 100; + e.y = Math.max(Math.min(e.y, bottom), 100); + if (e.y != sashRect.y) { + sashData.top = new FormAttachment(0, e.y); + store.setValue(PREFS_ALLOC_SASH, e.y); + mAllocationBase.layout(); + } + } + }); + + return mAllocationBase; + } + + @Override + public void dispose() { + mSortUpImg.dispose(); + mSortDownImg.dispose(); + super.dispose(); + } + + /** + * Sets the focus to the proper control inside the panel. + */ + @Override + public void setFocus() { + mAllocationTable.setFocus(); + } + + /** + * Sent when an existing client information changed. + *

+ * This is sent from a non UI thread. + * @param client the updated client. + * @param changeMask the bit mask describing the changed properties. It can contain + * any of the following values: {@link Client#CHANGE_INFO}, {@link Client#CHANGE_NAME} + * {@link Client#CHANGE_DEBUGGER_STATUS}, {@link Client#CHANGE_THREAD_MODE}, + * {@link Client#CHANGE_THREAD_DATA}, {@link Client#CHANGE_HEAP_MODE}, + * {@link Client#CHANGE_HEAP_DATA}, {@link Client#CHANGE_NATIVE_HEAP_DATA} + * + * @see IClientChangeListener#clientChanged(Client, int) + */ + @Override + public void clientChanged(final Client client, int changeMask) { + if (client == getCurrentClient()) { + if ((changeMask & Client.CHANGE_HEAP_ALLOCATIONS) != 0) { + try { + mAllocationTable.getDisplay().asyncExec(new Runnable() { + @Override + public void run() { + mAllocationViewer.refresh(); + updateAllocationStackCall(); + } + }); + } catch (SWTException e) { + // widget is disposed, we do nothing + } + } else if ((changeMask & Client.CHANGE_HEAP_ALLOCATION_STATUS) != 0) { + try { + mAllocationTable.getDisplay().asyncExec(new Runnable() { + @Override + public void run() { + setUpButtons(true, client.getClientData().getAllocationStatus()); + } + }); + } catch (SWTException e) { + // widget is disposed, we do nothing + } + } + } + } + + /** + * Sent when a new device is selected. The new device can be accessed + * with {@link #getCurrentDevice()}. + */ + @Override + public void deviceSelected() { + // pass + } + + /** + * Sent when a new client is selected. The new client can be accessed + * with {@link #getCurrentClient()}. + */ + @Override + public void clientSelected() { + if (mAllocationTable.isDisposed()) { + return; + } + + Client client = getCurrentClient(); + + mStackTracePanel.setCurrentClient(client); + mStackTracePanel.setViewerInput(null); // always empty on client selection change. + + if (client != null) { + setUpButtons(true /* enabled */, client.getClientData().getAllocationStatus()); + } else { + setUpButtons(false /* enabled */, AllocationTrackingStatus.OFF); + } + + mAllocationViewer.setInput(client); + } + + /** + * Updates the stack call of the currently selected thread. + *

+ * This must be called from the UI thread. + */ + private void updateAllocationStackCall() { + Client client = getCurrentClient(); + if (client != null) { + // get the current selection in the ThreadTable + AllocationInfo selectedAlloc = getAllocationSelection(null); + + if (selectedAlloc != null) { + updateAllocationStackTrace(selectedAlloc); + } else { + updateAllocationStackTrace(null); + } + } + } + + /** + * updates the stackcall of the specified allocation. If null the UI is emptied + * of current data. + * @param thread + */ + private void updateAllocationStackTrace(AllocationInfo alloc) { + mStackTracePanel.setViewerInput(alloc); + } + + @Override + protected void setTableFocusListener() { + addTableToFocusListener(mAllocationTable); + addTableToFocusListener(mStackTraceTable); + } + + /** + * Returns the current allocation selection or null if none is found. + * If a {@link ISelection} object is specified, the first {@link AllocationInfo} from this + * selection is returned, otherwise, the ISelection returned by + * {@link TableViewer#getSelection()} is used. + * @param selection the {@link ISelection} to use, or null + */ + private AllocationInfo getAllocationSelection(ISelection selection) { + if (selection == null) { + selection = mAllocationViewer.getSelection(); + } + + if (selection instanceof IStructuredSelection) { + IStructuredSelection structuredSelection = (IStructuredSelection)selection; + Object object = structuredSelection.getFirstElement(); + if (object instanceof AllocationInfo) { + return (AllocationInfo)object; + } + } + + return null; + } + + /** + * + * @param enabled + * @param trackingStatus + */ + private void setUpButtons(boolean enabled, AllocationTrackingStatus trackingStatus) { + if (enabled) { + switch (trackingStatus) { + case UNKNOWN: + mEnableButton.setText("?"); + mEnableButton.setEnabled(false); + mRequestButton.setEnabled(false); + break; + case OFF: + mEnableButton.setText("Start Tracking"); + mEnableButton.setEnabled(true); + mRequestButton.setEnabled(false); + break; + case ON: + mEnableButton.setText("Stop Tracking"); + mEnableButton.setEnabled(true); + mRequestButton.setEnabled(true); + break; + } + } else { + mEnableButton.setEnabled(false); + mRequestButton.setEnabled(false); + mEnableButton.setText("Start Tracking"); + } + } + + private void setSortColumn(final TableColumn column, SortMode sortMode) { + // set the new sort mode + mSorter.setSortMode(sortMode); + + mAllocationTable.setRedraw(false); + + // remove image from previous sort colum + if (mSortColumn != column) { + mSortColumn.setImage(null); + } + + mSortColumn = column; + if (mSorter.isDescending()) { + mSortColumn.setImage(mSortDownImg); + } else { + mSortColumn.setImage(mSortUpImg); + } + + mAllocationTable.setRedraw(true); + mAllocationViewer.refresh(); + } + + private AllocationInfo[] getFilteredAllocations(AllocationInfo[] allocations, + String filterText) { + ArrayList results = new ArrayList(); + // Using default locale here such that the locale-specific c + Locale locale = Locale.getDefault(); + filterText = filterText.toLowerCase(locale); + boolean fullTrace = mTraceFilterCheck.getSelection(); + + for (AllocationInfo info : allocations) { + if (info.filter(filterText, fullTrace, locale)) { + results.add(info); + } + } + + return results.toArray(new AllocationInfo[results.size()]); + } + +} + diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/BackgroundThread.java b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/BackgroundThread.java new file mode 100644 index 00000000..0ed4c950 --- /dev/null +++ b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/BackgroundThread.java @@ -0,0 +1,50 @@ +/* + * Copyright (C) 2007 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.ddmuilib; + +import com.android.ddmlib.Log; + +/** + * base background thread class. The class provides a synchronous quit method + * which sets a quitting flag to true. Inheriting classes should regularly test + * this flag with isQuitting() and should finish if the flag is + * true. + */ +public abstract class BackgroundThread extends Thread { + private boolean mQuit = false; + + /** + * Tell the thread to exit. This is usually called from the UI thread. The + * call is synchronous and will only return once the thread has terminated + * itself. + */ + public final void quit() { + mQuit = true; + Log.d("ddms", "Waiting for BackgroundThread to quit"); + try { + this.join(); + } catch (InterruptedException ie) { + ie.printStackTrace(); + } + } + + /** returns if the thread was asked to quit. */ + protected final boolean isQuitting() { + return mQuit; + } + +} diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/BaseHeapPanel.java b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/BaseHeapPanel.java new file mode 100644 index 00000000..3e66ea58 --- /dev/null +++ b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/BaseHeapPanel.java @@ -0,0 +1,193 @@ +/* + * Copyright (C) 2007 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.ddmuilib; + +import com.android.ddmlib.HeapSegment; +import com.android.ddmlib.ClientData.HeapData; +import com.android.ddmlib.HeapSegment.HeapSegmentElement; + +import org.eclipse.swt.graphics.ImageData; +import org.eclipse.swt.graphics.PaletteData; + +import java.io.ByteArrayOutputStream; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.Iterator; +import java.util.Map; +import java.util.TreeMap; + + +/** + * Base Panel for heap panels. + */ +public abstract class BaseHeapPanel extends TablePanel { + + /** store the processed heap segment, so that we don't recompute Image for nothing */ + protected byte[] mProcessedHeapData; + private Map> mHeapMap; + + /** + * Serialize the heap data into an array. The resulting array is available through + * getSerializedData(). + * @param heapData The heap data to serialize + * @return true if the data changed. + */ + protected boolean serializeHeapData(HeapData heapData) { + Collection heapSegments; + + // Atomically get and clear the heap data. + synchronized (heapData) { + // get the segments + heapSegments = heapData.getHeapSegments(); + + + if (heapSegments != null) { + // if they are not null, we never processed them. + // Before we process then, we drop them from the HeapData + heapData.clearHeapData(); + + // process them into a linear byte[] + doSerializeHeapData(heapSegments); + heapData.setProcessedHeapData(mProcessedHeapData); + heapData.setProcessedHeapMap(mHeapMap); + + } else { + // the heap segments are null. Let see if the heapData contains a + // list that is already processed. + + byte[] pixData = heapData.getProcessedHeapData(); + + // and compare it to the one we currently have in the panel. + if (pixData == mProcessedHeapData) { + // looks like its the same + return false; + } else { + mProcessedHeapData = pixData; + } + + Map> heapMap = + heapData.getProcessedHeapMap(); + mHeapMap = heapMap; + } + } + + return true; + } + + /** + * Returns the serialized heap data + */ + protected byte[] getSerializedData() { + return mProcessedHeapData; + } + + /** + * Processes and serialize the heapData. + *

+ * The resulting serialized array is {@link #mProcessedHeapData}. + *

+ * the resulting map is {@link #mHeapMap}. + * @param heapData the collection of {@link HeapSegment} that forms the heap data. + */ + private void doSerializeHeapData(Collection heapData) { + mHeapMap = new TreeMap>(); + + Iterator iterator; + ByteArrayOutputStream out; + + out = new ByteArrayOutputStream(4 * 1024); + + iterator = heapData.iterator(); + while (iterator.hasNext()) { + HeapSegment hs = iterator.next(); + + HeapSegmentElement e = null; + while (true) { + int v; + + e = hs.getNextElement(null); + if (e == null) { + break; + } + + if (e.getSolidity() == HeapSegmentElement.SOLIDITY_FREE) { + v = 1; + } else { + v = e.getKind() + 2; + } + + // put the element in the map + ArrayList elementList = mHeapMap.get(v); + if (elementList == null) { + elementList = new ArrayList(); + mHeapMap.put(v, elementList); + } + elementList.add(e); + + + int len = e.getLength() / 8; + while (len > 0) { + out.write(v); + --len; + } + } + } + mProcessedHeapData = out.toByteArray(); + + // sort the segment element in the heap info. + Collection> elementLists = mHeapMap.values(); + for (ArrayList elementList : elementLists) { + Collections.sort(elementList); + } + } + + /** + * Creates a linear image of the heap data. + * @param pixData + * @param h + * @param palette + * @return + */ + protected ImageData createLinearHeapImage(byte[] pixData, int h, PaletteData palette) { + int w = pixData.length / h; + if (pixData.length % h != 0) { + w++; + } + + // Create the heap image. + ImageData id = new ImageData(w, h, 8, palette); + + int x = 0; + int y = 0; + for (byte b : pixData) { + if (b >= 0) { + id.setPixel(x, y, b); + } + + y++; + if (y >= h) { + y = 0; + x++; + } + } + + return id; + } + + +} diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/ClientDisplayPanel.java b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/ClientDisplayPanel.java new file mode 100644 index 00000000..a7119337 --- /dev/null +++ b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/ClientDisplayPanel.java @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2007 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.ddmuilib; + +import com.android.ddmlib.AndroidDebugBridge; +import com.android.ddmlib.AndroidDebugBridge.IClientChangeListener; + +public abstract class ClientDisplayPanel extends SelectionDependentPanel + implements IClientChangeListener { + + @Override + protected void postCreation() { + AndroidDebugBridge.addClientChangeListener(this); + } + + public void dispose() { + AndroidDebugBridge.removeClientChangeListener(this); + } +} diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/DdmUiPreferences.java b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/DdmUiPreferences.java new file mode 100644 index 00000000..db3642b1 --- /dev/null +++ b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/DdmUiPreferences.java @@ -0,0 +1,79 @@ +/* + * Copyright (C) 2007 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.ddmuilib; + +import org.eclipse.jface.preference.IPreferenceStore; + +/** + * Preference entry point for ddmuilib. Allows the lib to access a preference + * store (org.eclipse.jface.preference.IPreferenceStore) defined by the + * application that includes the lib. + */ +public final class DdmUiPreferences { + + public static final int DEFAULT_THREAD_REFRESH_INTERVAL = 4; // seconds + + private static int sThreadRefreshInterval = DEFAULT_THREAD_REFRESH_INTERVAL; + + private static IPreferenceStore mStore; + + private static String sSymbolLocation =""; //$NON-NLS-1$ + private static String sAddr2LineLocation =""; //$NON-NLS-1$ + private static String sTraceviewLocation =""; //$NON-NLS-1$ + + public static void setStore(IPreferenceStore store) { + mStore = store; + } + + public static IPreferenceStore getStore() { + return mStore; + } + + public static int getThreadRefreshInterval() { + return sThreadRefreshInterval; + } + + public static void setThreadRefreshInterval(int port) { + sThreadRefreshInterval = port; + } + + public static String getSymbolDirectory() { + return sSymbolLocation; + } + + public static void setSymbolsLocation(String location) { + sSymbolLocation = location; + } + + public static String getAddr2Line() { + return sAddr2LineLocation; + } + + public static void setAddr2LineLocation(String location) { + sAddr2LineLocation = location; + } + + public static String getTraceview() { + return sTraceviewLocation; + } + + public static void setTraceviewLocation(String location) { + sTraceviewLocation = location; + } + + +} diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/DevicePanel.java b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/DevicePanel.java new file mode 100644 index 00000000..68f23b7b --- /dev/null +++ b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/DevicePanel.java @@ -0,0 +1,830 @@ +/* + * Copyright (C) 2007 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.ddmuilib; + +import com.android.ddmlib.AndroidDebugBridge; +import com.android.ddmlib.AndroidDebugBridge.IClientChangeListener; +import com.android.ddmlib.AndroidDebugBridge.IDebugBridgeChangeListener; +import com.android.ddmlib.AndroidDebugBridge.IDeviceChangeListener; +import com.android.ddmlib.Client; +import com.android.ddmlib.ClientData; +import com.android.ddmlib.ClientData.DebuggerStatus; +import com.android.ddmlib.DdmPreferences; +import com.android.ddmlib.IDevice; +import com.android.ddmlib.IDevice.DeviceState; + +import org.eclipse.jface.preference.IPreferenceStore; +import org.eclipse.jface.viewers.ILabelProviderListener; +import org.eclipse.jface.viewers.ITableLabelProvider; +import org.eclipse.jface.viewers.ITreeContentProvider; +import org.eclipse.jface.viewers.TreePath; +import org.eclipse.jface.viewers.TreeSelection; +import org.eclipse.jface.viewers.TreeViewer; +import org.eclipse.jface.viewers.Viewer; +import org.eclipse.swt.SWT; +import org.eclipse.swt.SWTException; +import org.eclipse.swt.events.SelectionAdapter; +import org.eclipse.swt.events.SelectionEvent; +import org.eclipse.swt.graphics.Image; +import org.eclipse.swt.layout.FillLayout; +import org.eclipse.swt.widgets.Composite; +import org.eclipse.swt.widgets.Control; +import org.eclipse.swt.widgets.Display; +import org.eclipse.swt.widgets.Tree; +import org.eclipse.swt.widgets.TreeColumn; +import org.eclipse.swt.widgets.TreeItem; + +import java.util.ArrayList; + +/** + * A display of both the devices and their clients. + */ +public final class DevicePanel extends Panel implements IDebugBridgeChangeListener, + IDeviceChangeListener, IClientChangeListener { + + private final static String PREFS_COL_NAME_SERIAL = "devicePanel.Col0"; //$NON-NLS-1$ + private final static String PREFS_COL_PID_STATE = "devicePanel.Col1"; //$NON-NLS-1$ + private final static String PREFS_COL_PORT_BUILD = "devicePanel.Col4"; //$NON-NLS-1$ + + private final static int DEVICE_COL_SERIAL = 0; + private final static int DEVICE_COL_STATE = 1; + // col 2, 3 not used. + private final static int DEVICE_COL_BUILD = 4; + + private final static int CLIENT_COL_NAME = 0; + private final static int CLIENT_COL_PID = 1; + private final static int CLIENT_COL_THREAD = 2; + private final static int CLIENT_COL_HEAP = 3; + private final static int CLIENT_COL_PORT = 4; + + public final static int ICON_WIDTH = 16; + public final static String ICON_THREAD = "thread.png"; //$NON-NLS-1$ + public final static String ICON_HEAP = "heap.png"; //$NON-NLS-1$ + public final static String ICON_HALT = "halt.png"; //$NON-NLS-1$ + public final static String ICON_GC = "gc.png"; //$NON-NLS-1$ + public final static String ICON_HPROF = "hprof.png"; //$NON-NLS-1$ + public final static String ICON_TRACING_START = "tracing_start.png"; //$NON-NLS-1$ + public final static String ICON_TRACING_STOP = "tracing_stop.png"; //$NON-NLS-1$ + + private IDevice mCurrentDevice; + private Client mCurrentClient; + + private Tree mTree; + private TreeViewer mTreeViewer; + + private Image mDeviceImage; + private Image mEmulatorImage; + + private Image mThreadImage; + private Image mHeapImage; + private Image mWaitingImage; + private Image mDebuggerImage; + private Image mDebugErrorImage; + + private final ArrayList mListeners = new ArrayList(); + + private final ArrayList mDevicesToExpand = new ArrayList(); + + private boolean mAdvancedPortSupport; + + /** + * A Content provider for the {@link TreeViewer}. + *

+ * The input is a {@link AndroidDebugBridge}. First level elements are {@link IDevice} objects, + * and second level elements are {@link Client} object. + */ + private class ContentProvider implements ITreeContentProvider { + @Override + public Object[] getChildren(Object parentElement) { + if (parentElement instanceof IDevice) { + return ((IDevice)parentElement).getClients(); + } + return new Object[0]; + } + + @Override + public Object getParent(Object element) { + if (element instanceof Client) { + return ((Client)element).getDevice(); + } + return null; + } + + @Override + public boolean hasChildren(Object element) { + if (element instanceof IDevice) { + return ((IDevice)element).hasClients(); + } + + // Clients never have children. + return false; + } + + @Override + public Object[] getElements(Object inputElement) { + if (inputElement instanceof AndroidDebugBridge) { + return ((AndroidDebugBridge)inputElement).getDevices(); + } + return new Object[0]; + } + + @Override + public void dispose() { + // pass + } + + @Override + public void inputChanged(Viewer viewer, Object oldInput, Object newInput) { + // pass + } + } + + /** + * A Label Provider for the {@link TreeViewer} in {@link DevicePanel}. It provides + * labels and images for {@link IDevice} and {@link Client} objects. + */ + private class LabelProvider implements ITableLabelProvider { + private static final String DEVICE_MODEL_PROPERTY = "ro.product.model"; //$NON-NLS-1$ + private static final String DEVICE_MANUFACTURER_PROPERTY = "ro.product.manufacturer"; //$NON-NLS-1$ + + @Override + public Image getColumnImage(Object element, int columnIndex) { + if (columnIndex == DEVICE_COL_SERIAL && element instanceof IDevice) { + IDevice device = (IDevice)element; + if (device.isEmulator()) { + return mEmulatorImage; + } + + return mDeviceImage; + } else if (element instanceof Client) { + Client client = (Client)element; + ClientData cd = client.getClientData(); + + switch (columnIndex) { + case CLIENT_COL_NAME: + switch (cd.getDebuggerConnectionStatus()) { + case DEFAULT: + return null; + case WAITING: + return mWaitingImage; + case ATTACHED: + return mDebuggerImage; + case ERROR: + return mDebugErrorImage; + } + return null; + case CLIENT_COL_THREAD: + if (client.isThreadUpdateEnabled()) { + return mThreadImage; + } + return null; + case CLIENT_COL_HEAP: + if (client.isHeapUpdateEnabled()) { + return mHeapImage; + } + return null; + } + } + return null; + } + + @Override + public String getColumnText(Object element, int columnIndex) { + if (element instanceof IDevice) { + IDevice device = (IDevice)element; + switch (columnIndex) { + case DEVICE_COL_SERIAL: + return getDeviceName(device); + case DEVICE_COL_STATE: + return getStateString(device); + case DEVICE_COL_BUILD: { + String version = device.getProperty(IDevice.PROP_BUILD_VERSION); + if (version != null) { + String debuggable = device.getProperty(IDevice.PROP_DEBUGGABLE); + if (device.isEmulator()) { + String avdName = device.getAvdName(); + if (avdName == null) { + avdName = "?"; // the device is probably not online yet, so + // we don't know its AVD name just yet. + } + if (debuggable != null && debuggable.equals("1")) { //$NON-NLS-1$ + return String.format("%1$s [%2$s, debug]", avdName, + version); + } else { + return String.format("%1$s [%2$s]", avdName, version); //$NON-NLS-1$ + } + } else { + if (debuggable != null && debuggable.equals("1")) { //$NON-NLS-1$ + return String.format("%1$s, debug", version); + } else { + return String.format("%1$s", version); //$NON-NLS-1$ + } + } + } else { + return "unknown"; + } + } + } + } else if (element instanceof Client) { + Client client = (Client)element; + ClientData cd = client.getClientData(); + + switch (columnIndex) { + case CLIENT_COL_NAME: + String name = cd.getClientDescription(); + if (name != null) { + return name; + } + return "?"; + case CLIENT_COL_PID: + return Integer.toString(cd.getPid()); + case CLIENT_COL_PORT: + if (mAdvancedPortSupport) { + int port = client.getDebuggerListenPort(); + String portString = "?"; + if (port != 0) { + portString = Integer.toString(port); + } + if (client.isSelectedClient()) { + return String.format("%1$s / %2$d", portString, //$NON-NLS-1$ + DdmPreferences.getSelectedDebugPort()); + } + + return portString; + } + } + } + return null; + } + + private String getDeviceName(IDevice device) { + StringBuilder sb = new StringBuilder(20); + sb.append(device.getSerialNumber()); + + if (device.isEmulator()) { + sb.append(String.format(" [%s]", device.getAvdName())); + } else { + String manufacturer = device.getProperty(DEVICE_MANUFACTURER_PROPERTY); + manufacturer = cleanupStringForDisplay(manufacturer); + + String model = device.getProperty(DEVICE_MODEL_PROPERTY); + model = cleanupStringForDisplay(model); + + boolean hasManufacturer = manufacturer.length() > 0; + boolean hasModel = model.length() > 0; + if (hasManufacturer || hasModel) { + sb.append(" ["); //$NON-NLS-1$ + sb.append(manufacturer); + + if (hasManufacturer && hasModel) { + sb.append(':'); + } + + sb.append(model); + sb.append(']'); + } + } + + return sb.toString(); + } + + private String cleanupStringForDisplay(String s) { + if (s == null) { + return ""; + } + + StringBuilder sb = new StringBuilder(s.length()); + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + + if (Character.isLetterOrDigit(c)) { + sb.append(c); + } + } + + return sb.toString(); + } + + @Override + public void addListener(ILabelProviderListener listener) { + // pass + } + + @Override + public void dispose() { + // pass + } + + @Override + public boolean isLabelProperty(Object element, String property) { + // pass + return false; + } + + @Override + public void removeListener(ILabelProviderListener listener) { + // pass + } + } + + /** + * Classes which implement this interface provide methods that deals + * with {@link IDevice} and {@link Client} selection changes coming from the ui. + */ + public interface IUiSelectionListener { + /** + * Sent when a new {@link IDevice} and {@link Client} are selected. + * @param selectedDevice the selected device. If null, no devices are selected. + * @param selectedClient The selected client. If null, no clients are selected. + */ + public void selectionChanged(IDevice selectedDevice, Client selectedClient); + } + + /** + * Creates the {@link DevicePanel} object. + * @param loader + * @param advancedPortSupport if true the device panel will add support for selected client port + * and display the ports in the ui. + */ + public DevicePanel(boolean advancedPortSupport) { + mAdvancedPortSupport = advancedPortSupport; + } + + public void addSelectionListener(IUiSelectionListener listener) { + mListeners.add(listener); + } + + public void removeSelectionListener(IUiSelectionListener listener) { + mListeners.remove(listener); + } + + @Override + protected Control createControl(Composite parent) { + loadImages(parent.getDisplay()); + + parent.setLayout(new FillLayout()); + + // create the tree and its column + mTree = new Tree(parent, SWT.SINGLE | SWT.FULL_SELECTION); + mTree.setHeaderVisible(true); + mTree.setLinesVisible(true); + + IPreferenceStore store = DdmUiPreferences.getStore(); + + TableHelper.createTreeColumn(mTree, "Name", SWT.LEFT, + "com.android.home", //$NON-NLS-1$ + PREFS_COL_NAME_SERIAL, store); + TableHelper.createTreeColumn(mTree, "", SWT.LEFT, //$NON-NLS-1$ + "Offline", //$NON-NLS-1$ + PREFS_COL_PID_STATE, store); + + TreeColumn col = new TreeColumn(mTree, SWT.NONE); + col.setWidth(ICON_WIDTH + 8); + col.setResizable(false); + col = new TreeColumn(mTree, SWT.NONE); + col.setWidth(ICON_WIDTH + 8); + col.setResizable(false); + + TableHelper.createTreeColumn(mTree, "", SWT.LEFT, //$NON-NLS-1$ + "9999-9999", //$NON-NLS-1$ + PREFS_COL_PORT_BUILD, store); + + // create the tree viewer + mTreeViewer = new TreeViewer(mTree); + + // make the device auto expanded. + mTreeViewer.setAutoExpandLevel(TreeViewer.ALL_LEVELS); + + // set up the content and label providers. + mTreeViewer.setContentProvider(new ContentProvider()); + mTreeViewer.setLabelProvider(new LabelProvider()); + + mTree.addSelectionListener(new SelectionAdapter() { + @Override + public void widgetSelected(SelectionEvent e) { + notifyListeners(); + } + }); + + return mTree; + } + + /** + * Sets the focus to the proper control inside the panel. + */ + @Override + public void setFocus() { + mTree.setFocus(); + } + + @Override + protected void postCreation() { + // ask for notification of changes in AndroidDebugBridge (a new one is created when + // adb is restarted from a different location), IDevice and Client objects. + AndroidDebugBridge.addDebugBridgeChangeListener(this); + AndroidDebugBridge.addDeviceChangeListener(this); + AndroidDebugBridge.addClientChangeListener(this); + } + + public void dispose() { + AndroidDebugBridge.removeDebugBridgeChangeListener(this); + AndroidDebugBridge.removeDeviceChangeListener(this); + AndroidDebugBridge.removeClientChangeListener(this); + } + + /** + * Returns the selected {@link Client}. May be null. + */ + public Client getSelectedClient() { + return mCurrentClient; + } + + /** + * Returns the selected {@link IDevice}. If a {@link Client} is selected, it returns the + * IDevice object containing the client. + */ + public IDevice getSelectedDevice() { + return mCurrentDevice; + } + + /** + * Kills the selected {@link Client} by sending its VM a halt command. + */ + public void killSelectedClient() { + if (mCurrentClient != null) { + Client client = mCurrentClient; + + // reset the selection to the device. + TreePath treePath = new TreePath(new Object[] { mCurrentDevice }); + TreeSelection treeSelection = new TreeSelection(treePath); + mTreeViewer.setSelection(treeSelection); + + client.kill(); + } + } + + /** + * Forces a GC on the selected {@link Client}. + */ + public void forceGcOnSelectedClient() { + if (mCurrentClient != null) { + mCurrentClient.executeGarbageCollector(); + } + } + + public void dumpHprof() { + if (mCurrentClient != null) { + mCurrentClient.dumpHprof(); + } + } + + public void toggleMethodProfiling() { + if (mCurrentClient != null) { + mCurrentClient.toggleMethodProfiling(); + } + } + + public void setEnabledHeapOnSelectedClient(boolean enable) { + if (mCurrentClient != null) { + mCurrentClient.setHeapUpdateEnabled(enable); + } + } + + public void setEnabledThreadOnSelectedClient(boolean enable) { + if (mCurrentClient != null) { + mCurrentClient.setThreadUpdateEnabled(enable); + } + } + + /** + * Sent when a new {@link AndroidDebugBridge} is started. + *

+ * This is sent from a non UI thread. + * @param bridge the new {@link AndroidDebugBridge} object. + * + * @see IDebugBridgeChangeListener#serverChanged(AndroidDebugBridge) + */ + @Override + public void bridgeChanged(final AndroidDebugBridge bridge) { + if (mTree.isDisposed() == false) { + exec(new Runnable() { + @Override + public void run() { + if (mTree.isDisposed() == false) { + // set up the data source. + mTreeViewer.setInput(bridge); + + // notify the listener of a possible selection change. + notifyListeners(); + } else { + // tree is disposed, we need to do something. + // lets remove ourselves from the listener. + AndroidDebugBridge.removeDebugBridgeChangeListener(DevicePanel.this); + AndroidDebugBridge.removeDeviceChangeListener(DevicePanel.this); + AndroidDebugBridge.removeClientChangeListener(DevicePanel.this); + } + } + }); + } + + // all current devices are obsolete + synchronized (mDevicesToExpand) { + mDevicesToExpand.clear(); + } + } + + /** + * Sent when the a device is connected to the {@link AndroidDebugBridge}. + *

+ * This is sent from a non UI thread. + * @param device the new device. + * + * @see IDeviceChangeListener#deviceConnected(IDevice) + */ + @Override + public void deviceConnected(IDevice device) { + exec(new Runnable() { + @Override + public void run() { + if (mTree.isDisposed() == false) { + // refresh all + mTreeViewer.refresh(); + + // notify the listener of a possible selection change. + notifyListeners(); + } else { + // tree is disposed, we need to do something. + // lets remove ourselves from the listener. + AndroidDebugBridge.removeDebugBridgeChangeListener(DevicePanel.this); + AndroidDebugBridge.removeDeviceChangeListener(DevicePanel.this); + AndroidDebugBridge.removeClientChangeListener(DevicePanel.this); + } + } + }); + + // if it doesn't have clients yet, it'll need to be manually expanded when it gets them. + if (device.hasClients() == false) { + synchronized (mDevicesToExpand) { + mDevicesToExpand.add(device); + } + } + } + + /** + * Sent when the a device is connected to the {@link AndroidDebugBridge}. + *

+ * This is sent from a non UI thread. + * @param device the new device. + * + * @see IDeviceChangeListener#deviceDisconnected(IDevice) + */ + @Override + public void deviceDisconnected(IDevice device) { + deviceConnected(device); + + // just in case, we remove it from the list of devices to expand. + synchronized (mDevicesToExpand) { + mDevicesToExpand.remove(device); + } + } + + /** + * Sent when a device data changed, or when clients are started/terminated on the device. + *

+ * This is sent from a non UI thread. + * @param device the device that was updated. + * @param changeMask the mask indicating what changed. + * + * @see IDeviceChangeListener#deviceChanged(IDevice) + */ + @Override + public void deviceChanged(final IDevice device, int changeMask) { + boolean expand = false; + synchronized (mDevicesToExpand) { + int index = mDevicesToExpand.indexOf(device); + if (device.hasClients() && index != -1) { + mDevicesToExpand.remove(index); + expand = true; + } + } + + final boolean finalExpand = expand; + + exec(new Runnable() { + @Override + public void run() { + if (mTree.isDisposed() == false) { + // look if the current device is selected. This is done in case the current + // client of this particular device was killed. In this case, we'll need to + // manually reselect the device. + + IDevice selectedDevice = getSelectedDevice(); + + // refresh the device + mTreeViewer.refresh(device); + + // if the selected device was the changed device and the new selection is + // empty, we reselect the device. + if (selectedDevice == device && mTreeViewer.getSelection().isEmpty()) { + mTreeViewer.setSelection(new TreeSelection(new TreePath( + new Object[] { device }))); + } + + // notify the listener of a possible selection change. + notifyListeners(); + + if (finalExpand) { + mTreeViewer.setExpandedState(device, true); + } + } else { + // tree is disposed, we need to do something. + // lets remove ourselves from the listener. + AndroidDebugBridge.removeDebugBridgeChangeListener(DevicePanel.this); + AndroidDebugBridge.removeDeviceChangeListener(DevicePanel.this); + AndroidDebugBridge.removeClientChangeListener(DevicePanel.this); + } + } + }); + } + + /** + * Sent when an existing client information changed. + *

+ * This is sent from a non UI thread. + * @param client the updated client. + * @param changeMask the bit mask describing the changed properties. It can contain + * any of the following values: {@link Client#CHANGE_INFO}, + * {@link Client#CHANGE_DEBUGGER_STATUS}, {@link Client#CHANGE_THREAD_MODE}, + * {@link Client#CHANGE_THREAD_DATA}, {@link Client#CHANGE_HEAP_MODE}, + * {@link Client#CHANGE_HEAP_DATA}, {@link Client#CHANGE_NATIVE_HEAP_DATA} + * + * @see IClientChangeListener#clientChanged(Client, int) + */ + @Override + public void clientChanged(final Client client, final int changeMask) { + exec(new Runnable() { + @Override + public void run() { + if (mTree.isDisposed() == false) { + // refresh the client + mTreeViewer.refresh(client); + + if ((changeMask & Client.CHANGE_DEBUGGER_STATUS) == + Client.CHANGE_DEBUGGER_STATUS && + client.getClientData().getDebuggerConnectionStatus() == + DebuggerStatus.WAITING) { + // make sure the device is expanded. Normally the setSelection below + // will auto expand, but the children of device may not already exist + // at this time. Forcing an expand will make the TreeViewer create them. + IDevice device = client.getDevice(); + if (mTreeViewer.getExpandedState(device) == false) { + mTreeViewer.setExpandedState(device, true); + } + + // create and set the selection + TreePath treePath = new TreePath(new Object[] { device, client}); + TreeSelection treeSelection = new TreeSelection(treePath); + mTreeViewer.setSelection(treeSelection); + + if (mAdvancedPortSupport) { + client.setAsSelectedClient(); + } + + // notify the listener of a possible selection change. + notifyListeners(device, client); + } + } else { + // tree is disposed, we need to do something. + // lets remove ourselves from the listener. + AndroidDebugBridge.removeDebugBridgeChangeListener(DevicePanel.this); + AndroidDebugBridge.removeDeviceChangeListener(DevicePanel.this); + AndroidDebugBridge.removeClientChangeListener(DevicePanel.this); + } + } + }); + } + + private void loadImages(Display display) { + ImageLoader loader = ImageLoader.getDdmUiLibLoader(); + + if (mDeviceImage == null) { + mDeviceImage = loader.loadImage(display, "device.png", //$NON-NLS-1$ + ICON_WIDTH, ICON_WIDTH, + display.getSystemColor(SWT.COLOR_RED)); + } + if (mEmulatorImage == null) { + mEmulatorImage = loader.loadImage(display, + "emulator.png", ICON_WIDTH, ICON_WIDTH, //$NON-NLS-1$ + display.getSystemColor(SWT.COLOR_BLUE)); + } + if (mThreadImage == null) { + mThreadImage = loader.loadImage(display, ICON_THREAD, + ICON_WIDTH, ICON_WIDTH, + display.getSystemColor(SWT.COLOR_YELLOW)); + } + if (mHeapImage == null) { + mHeapImage = loader.loadImage(display, ICON_HEAP, + ICON_WIDTH, ICON_WIDTH, + display.getSystemColor(SWT.COLOR_BLUE)); + } + if (mWaitingImage == null) { + mWaitingImage = loader.loadImage(display, + "debug-wait.png", ICON_WIDTH, ICON_WIDTH, //$NON-NLS-1$ + display.getSystemColor(SWT.COLOR_RED)); + } + if (mDebuggerImage == null) { + mDebuggerImage = loader.loadImage(display, + "debug-attach.png", ICON_WIDTH, ICON_WIDTH, //$NON-NLS-1$ + display.getSystemColor(SWT.COLOR_GREEN)); + } + if (mDebugErrorImage == null) { + mDebugErrorImage = loader.loadImage(display, + "debug-error.png", ICON_WIDTH, ICON_WIDTH, //$NON-NLS-1$ + display.getSystemColor(SWT.COLOR_RED)); + } + } + + /** + * Returns a display string representing the state of the device. + * @param d the device + */ + private static String getStateString(IDevice d) { + DeviceState deviceState = d.getState(); + if (deviceState == DeviceState.ONLINE) { + return "Online"; + } else if (deviceState == DeviceState.OFFLINE) { + return "Offline"; + } else if (deviceState == DeviceState.BOOTLOADER) { + return "Bootloader"; + } + + return "??"; + } + + /** + * Executes the {@link Runnable} in the UI thread. + * @param runnable the runnable to execute. + */ + private void exec(Runnable runnable) { + try { + Display display = mTree.getDisplay(); + display.asyncExec(runnable); + } catch (SWTException e) { + // tree is disposed, we need to do something. lets remove ourselves from the listener. + AndroidDebugBridge.removeDebugBridgeChangeListener(this); + AndroidDebugBridge.removeDeviceChangeListener(this); + AndroidDebugBridge.removeClientChangeListener(this); + } + } + + private void notifyListeners() { + // get the selection + TreeItem[] items = mTree.getSelection(); + + Client client = null; + IDevice device = null; + + if (items.length == 1) { + Object object = items[0].getData(); + if (object instanceof Client) { + client = (Client)object; + device = client.getDevice(); + } else if (object instanceof IDevice) { + device = (IDevice)object; + } + } + + notifyListeners(device, client); + } + + private void notifyListeners(IDevice selectedDevice, Client selectedClient) { + if (selectedDevice != mCurrentDevice || selectedClient != mCurrentClient) { + mCurrentDevice = selectedDevice; + mCurrentClient = selectedClient; + + for (IUiSelectionListener listener : mListeners) { + // notify the listener with a try/catch-all to make sure this thread won't die + // because of an uncaught exception before all the listeners were notified. + try { + listener.selectionChanged(selectedDevice, selectedClient); + } catch (Exception e) { + } + } + } + } + +} diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/EmulatorControlPanel.java b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/EmulatorControlPanel.java new file mode 100644 index 00000000..82aed981 --- /dev/null +++ b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/EmulatorControlPanel.java @@ -0,0 +1,1463 @@ +/* + * Copyright (C) 2007 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.ddmuilib; + +import com.android.ddmlib.EmulatorConsole; +import com.android.ddmlib.EmulatorConsole.GsmMode; +import com.android.ddmlib.EmulatorConsole.GsmStatus; +import com.android.ddmlib.EmulatorConsole.NetworkStatus; +import com.android.ddmlib.IDevice; +import com.android.ddmuilib.location.CoordinateControls; +import com.android.ddmuilib.location.GpxParser; +import com.android.ddmuilib.location.GpxParser.Track; +import com.android.ddmuilib.location.KmlParser; +import com.android.ddmuilib.location.TrackContentProvider; +import com.android.ddmuilib.location.TrackLabelProvider; +import com.android.ddmuilib.location.TrackPoint; +import com.android.ddmuilib.location.WayPoint; +import com.android.ddmuilib.location.WayPointContentProvider; +import com.android.ddmuilib.location.WayPointLabelProvider; + +import org.eclipse.jface.dialogs.MessageDialog; +import org.eclipse.jface.preference.IPreferenceStore; +import org.eclipse.jface.viewers.ISelection; +import org.eclipse.jface.viewers.ISelectionChangedListener; +import org.eclipse.jface.viewers.IStructuredSelection; +import org.eclipse.jface.viewers.SelectionChangedEvent; +import org.eclipse.jface.viewers.TableViewer; +import org.eclipse.swt.SWT; +import org.eclipse.swt.SWTException; +import org.eclipse.swt.custom.ScrolledComposite; +import org.eclipse.swt.custom.StackLayout; +import org.eclipse.swt.events.ControlAdapter; +import org.eclipse.swt.events.ControlEvent; +import org.eclipse.swt.events.ModifyEvent; +import org.eclipse.swt.events.ModifyListener; +import org.eclipse.swt.events.SelectionAdapter; +import org.eclipse.swt.events.SelectionEvent; +import org.eclipse.swt.graphics.Image; +import org.eclipse.swt.graphics.Rectangle; +import org.eclipse.swt.layout.FillLayout; +import org.eclipse.swt.layout.GridData; +import org.eclipse.swt.layout.GridLayout; +import org.eclipse.swt.widgets.Button; +import org.eclipse.swt.widgets.Combo; +import org.eclipse.swt.widgets.Composite; +import org.eclipse.swt.widgets.Control; +import org.eclipse.swt.widgets.Display; +import org.eclipse.swt.widgets.FileDialog; +import org.eclipse.swt.widgets.Group; +import org.eclipse.swt.widgets.Label; +import org.eclipse.swt.widgets.TabFolder; +import org.eclipse.swt.widgets.TabItem; +import org.eclipse.swt.widgets.Table; +import org.eclipse.swt.widgets.Text; + +/** + * Panel to control the emulator using EmulatorConsole objects. + */ +public class EmulatorControlPanel extends SelectionDependentPanel { + + // default location: Patio outside Charlie's + private final static double DEFAULT_LONGITUDE = -122.084095; + private final static double DEFAULT_LATITUDE = 37.422006; + + private final static String SPEED_FORMAT = "Speed: %1$dX"; + + + /** + * Map between the display gsm mode and the internal tag used by the display. + */ + private final static String[][] GSM_MODES = new String[][] { + { "unregistered", GsmMode.UNREGISTERED.getTag() }, + { "home", GsmMode.HOME.getTag() }, + { "roaming", GsmMode.ROAMING.getTag() }, + { "searching", GsmMode.SEARCHING.getTag() }, + { "denied", GsmMode.DENIED.getTag() }, + }; + + private final static String[] NETWORK_SPEEDS = new String[] { + "Full", + "GSM", + "HSCSD", + "GPRS", + "EDGE", + "UMTS", + "HSDPA", + }; + + private final static String[] NETWORK_LATENCIES = new String[] { + "None", + "GPRS", + "EDGE", + "UMTS", + }; + + private final static int[] PLAY_SPEEDS = new int[] { 1, 2, 5, 10, 20, 50 }; + + private final static String RE_PHONE_NUMBER = "^[+#0-9]+$"; //$NON-NLS-1$ + private final static String PREFS_WAYPOINT_COL_NAME = "emulatorControl.waypoint.name"; //$NON-NLS-1$ + private final static String PREFS_WAYPOINT_COL_LONGITUDE = "emulatorControl.waypoint.longitude"; //$NON-NLS-1$ + private final static String PREFS_WAYPOINT_COL_LATITUDE = "emulatorControl.waypoint.latitude"; //$NON-NLS-1$ + private final static String PREFS_WAYPOINT_COL_ELEVATION = "emulatorControl.waypoint.elevation"; //$NON-NLS-1$ + private final static String PREFS_WAYPOINT_COL_DESCRIPTION = "emulatorControl.waypoint.desc"; //$NON-NLS-1$ + private final static String PREFS_TRACK_COL_NAME = "emulatorControl.track.name"; //$NON-NLS-1$ + private final static String PREFS_TRACK_COL_COUNT = "emulatorControl.track.count"; //$NON-NLS-1$ + private final static String PREFS_TRACK_COL_FIRST = "emulatorControl.track.first"; //$NON-NLS-1$ + private final static String PREFS_TRACK_COL_LAST = "emulatorControl.track.last"; //$NON-NLS-1$ + private final static String PREFS_TRACK_COL_COMMENT = "emulatorControl.track.comment"; //$NON-NLS-1$ + + private EmulatorConsole mEmulatorConsole; + + private Composite mParent; + + private Label mVoiceLabel; + private Combo mVoiceMode; + private Label mDataLabel; + private Combo mDataMode; + private Label mSpeedLabel; + private Combo mNetworkSpeed; + private Label mLatencyLabel; + private Combo mNetworkLatency; + + private Label mNumberLabel; + private Text mPhoneNumber; + + private Button mVoiceButton; + private Button mSmsButton; + + private Label mMessageLabel; + private Text mSmsMessage; + + private Button mCallButton; + private Button mCancelButton; + + private TabFolder mLocationFolders; + + private Button mDecimalButton; + private Button mSexagesimalButton; + private CoordinateControls mLongitudeControls; + private CoordinateControls mLatitudeControls; + private Button mGpxUploadButton; + private Table mGpxWayPointTable; + private Table mGpxTrackTable; + private Button mKmlUploadButton; + private Table mKmlWayPointTable; + + private Button mPlayGpxButton; + private Button mGpxBackwardButton; + private Button mGpxForwardButton; + private Button mGpxSpeedButton; + private Button mPlayKmlButton; + private Button mKmlBackwardButton; + private Button mKmlForwardButton; + private Button mKmlSpeedButton; + + private Image mPlayImage; + private Image mPauseImage; + + private Thread mPlayingThread; + private boolean mPlayingTrack; + private int mPlayDirection = 1; + private int mSpeed; + private int mSpeedIndex; + + private final SelectionAdapter mDirectionButtonAdapter = new SelectionAdapter() { + @Override + public void widgetSelected(SelectionEvent e) { + Button b = (Button)e.getSource(); + if (b.getSelection() == false) { + // basically the button was unselected, which we don't allow. + // so we reselect it. + b.setSelection(true); + return; + } + + // now handle selection change. + if (b == mGpxForwardButton || b == mKmlForwardButton) { + mGpxBackwardButton.setSelection(false); + mGpxForwardButton.setSelection(true); + mKmlBackwardButton.setSelection(false); + mKmlForwardButton.setSelection(true); + mPlayDirection = 1; + + } else { + mGpxBackwardButton.setSelection(true); + mGpxForwardButton.setSelection(false); + mKmlBackwardButton.setSelection(true); + mKmlForwardButton.setSelection(false); + mPlayDirection = -1; + } + } + }; + + private final SelectionAdapter mSpeedButtonAdapter = new SelectionAdapter() { + @Override + public void widgetSelected(SelectionEvent e) { + mSpeedIndex = (mSpeedIndex+1) % PLAY_SPEEDS.length; + mSpeed = PLAY_SPEEDS[mSpeedIndex]; + + mGpxSpeedButton.setText(String.format(SPEED_FORMAT, mSpeed)); + mGpxPlayControls.pack(); + mKmlSpeedButton.setText(String.format(SPEED_FORMAT, mSpeed)); + mKmlPlayControls.pack(); + + if (mPlayingThread != null) { + mPlayingThread.interrupt(); + } + } + }; + private Composite mKmlPlayControls; + private Composite mGpxPlayControls; + + + public EmulatorControlPanel() { + } + + /** + * Sent when a new device is selected. The new device can be accessed + * with {@link #getCurrentDevice()} + */ + @Override + public void deviceSelected() { + handleNewDevice(getCurrentDevice()); + } + + /** + * Sent when a new client is selected. The new client can be accessed + * with {@link #getCurrentClient()} + */ + @Override + public void clientSelected() { + // pass + } + + /** + * Creates a control capable of displaying some information. This is + * called once, when the application is initializing, from the UI thread. + */ + @Override + protected Control createControl(Composite parent) { + mParent = parent; + + final ScrolledComposite scollingParent = new ScrolledComposite(parent, SWT.V_SCROLL); + scollingParent.setExpandVertical(true); + scollingParent.setExpandHorizontal(true); + scollingParent.setLayoutData(new GridData(GridData.FILL_BOTH)); + + final Composite top = new Composite(scollingParent, SWT.NONE); + scollingParent.setContent(top); + top.setLayout(new GridLayout(1, false)); + + // set the resize for the scrolling to work (why isn't that done automatically?!?) + scollingParent.addControlListener(new ControlAdapter() { + @Override + public void controlResized(ControlEvent e) { + Rectangle r = scollingParent.getClientArea(); + scollingParent.setMinSize(top.computeSize(r.width, SWT.DEFAULT)); + } + }); + + createRadioControls(top); + + createCallControls(top); + + createLocationControls(top); + + doEnable(false); + + top.layout(); + Rectangle r = scollingParent.getClientArea(); + scollingParent.setMinSize(top.computeSize(r.width, SWT.DEFAULT)); + + return scollingParent; + } + + /** + * Create Radio (on/off/roaming, for voice/data) controls. + * @param top + */ + private void createRadioControls(final Composite top) { + Group g1 = new Group(top, SWT.NONE); + g1.setLayoutData(new GridData(GridData.FILL_HORIZONTAL)); + g1.setLayout(new GridLayout(2, false)); + g1.setText("Telephony Status"); + + // the inside of the group is 2 composite so that all the column of the controls (mainly + // combos) have the same width, while not taking the whole screen width + Composite insideGroup = new Composite(g1, SWT.NONE); + GridLayout gl = new GridLayout(4, false); + gl.marginBottom = gl.marginHeight = gl.marginLeft = gl.marginRight = 0; + insideGroup.setLayout(gl); + + mVoiceLabel = new Label(insideGroup, SWT.NONE); + mVoiceLabel.setText("Voice:"); + mVoiceLabel.setAlignment(SWT.RIGHT); + + mVoiceMode = new Combo(insideGroup, SWT.READ_ONLY); + mVoiceMode.setLayoutData(new GridData(GridData.FILL_HORIZONTAL)); + for (String[] mode : GSM_MODES) { + mVoiceMode.add(mode[0]); + } + mVoiceMode.addSelectionListener(new SelectionAdapter() { + // called when selection changes + @Override + public void widgetSelected(SelectionEvent e) { + setVoiceMode(mVoiceMode.getSelectionIndex()); + } + }); + + mSpeedLabel = new Label(insideGroup, SWT.NONE); + mSpeedLabel.setText("Speed:"); + mSpeedLabel.setAlignment(SWT.RIGHT); + + mNetworkSpeed = new Combo(insideGroup, SWT.READ_ONLY); + mNetworkSpeed.setLayoutData(new GridData(GridData.FILL_HORIZONTAL)); + for (String mode : NETWORK_SPEEDS) { + mNetworkSpeed.add(mode); + } + mNetworkSpeed.addSelectionListener(new SelectionAdapter() { + // called when selection changes + @Override + public void widgetSelected(SelectionEvent e) { + setNetworkSpeed(mNetworkSpeed.getSelectionIndex()); + } + }); + + mDataLabel = new Label(insideGroup, SWT.NONE); + mDataLabel.setText("Data:"); + mDataLabel.setAlignment(SWT.RIGHT); + + mDataMode = new Combo(insideGroup, SWT.READ_ONLY); + mDataMode.setLayoutData(new GridData(GridData.FILL_HORIZONTAL)); + for (String[] mode : GSM_MODES) { + mDataMode.add(mode[0]); + } + mDataMode.addSelectionListener(new SelectionAdapter() { + // called when selection changes + @Override + public void widgetSelected(SelectionEvent e) { + setDataMode(mDataMode.getSelectionIndex()); + } + }); + + mLatencyLabel = new Label(insideGroup, SWT.NONE); + mLatencyLabel.setText("Latency:"); + mLatencyLabel.setAlignment(SWT.RIGHT); + + mNetworkLatency = new Combo(insideGroup, SWT.READ_ONLY); + mNetworkLatency.setLayoutData(new GridData(GridData.FILL_HORIZONTAL)); + for (String mode : NETWORK_LATENCIES) { + mNetworkLatency.add(mode); + } + mNetworkLatency.addSelectionListener(new SelectionAdapter() { + // called when selection changes + @Override + public void widgetSelected(SelectionEvent e) { + setNetworkLatency(mNetworkLatency.getSelectionIndex()); + } + }); + + // now an empty label to take the rest of the width of the group + Label l = new Label(g1, SWT.NONE); + l.setLayoutData(new GridData(GridData.FILL_HORIZONTAL)); + } + + /** + * Create Voice/SMS call/hang up controls + * @param top + */ + private void createCallControls(final Composite top) { + GridLayout gl; + Group g2 = new Group(top, SWT.NONE); + g2.setLayoutData(new GridData(GridData.FILL_HORIZONTAL)); + g2.setLayout(new GridLayout(1, false)); + g2.setText("Telephony Actions"); + + // horizontal composite for label + text field + Composite phoneComp = new Composite(g2, SWT.NONE); + phoneComp.setLayoutData(new GridData(GridData.FILL_BOTH)); + gl = new GridLayout(2, false); + gl.marginBottom = gl.marginHeight = gl.marginLeft = gl.marginRight = 0; + phoneComp.setLayout(gl); + + mNumberLabel = new Label(phoneComp, SWT.NONE); + mNumberLabel.setText("Incoming number:"); + + mPhoneNumber = new Text(phoneComp, SWT.BORDER | SWT.LEFT | SWT.SINGLE); + mPhoneNumber.setLayoutData(new GridData(GridData.FILL_HORIZONTAL)); + mPhoneNumber.addModifyListener(new ModifyListener() { + @Override + public void modifyText(ModifyEvent e) { + // Reenable the widgets based on the content of the text. + // doEnable checks the validity of the phone number to enable/disable some + // widgets. + // Looks like we're getting a callback at creation time, so we can't + // suppose that we are enabled when the text is modified... + doEnable(mEmulatorConsole != null); + } + }); + + mVoiceButton = new Button(phoneComp, SWT.RADIO); + GridData gd = new GridData(); + gd.horizontalSpan = 2; + mVoiceButton.setText("Voice"); + mVoiceButton.setLayoutData(gd); + mVoiceButton.setEnabled(false); + mVoiceButton.setSelection(true); + mVoiceButton.addSelectionListener(new SelectionAdapter() { + // called when selection changes + @Override + public void widgetSelected(SelectionEvent e) { + doEnable(true); + + if (mVoiceButton.getSelection()) { + mCallButton.setText("Call"); + } else { + mCallButton.setText("Send"); + } + } + }); + + mSmsButton = new Button(phoneComp, SWT.RADIO); + mSmsButton.setText("SMS"); + gd = new GridData(); + gd.horizontalSpan = 2; + mSmsButton.setLayoutData(gd); + mSmsButton.setEnabled(false); + // Since there are only 2 radio buttons, we can put a listener on only one (they + // are both called on select and unselect event. + + mMessageLabel = new Label(phoneComp, SWT.NONE); + gd = new GridData(); + gd.verticalAlignment = SWT.TOP; + mMessageLabel.setLayoutData(gd); + mMessageLabel.setText("Message:"); + mMessageLabel.setEnabled(false); + + mSmsMessage = new Text(phoneComp, SWT.BORDER | SWT.LEFT | SWT.MULTI | SWT.WRAP | SWT.V_SCROLL); + mSmsMessage.setLayoutData(gd = new GridData(GridData.FILL_HORIZONTAL)); + gd.heightHint = 70; + mSmsMessage.setEnabled(false); + + // composite to put the 2 buttons horizontally + Composite g2ButtonComp = new Composite(g2, SWT.NONE); + g2ButtonComp.setLayoutData(new GridData(GridData.FILL_HORIZONTAL)); + gl = new GridLayout(2, false); + gl.marginWidth = gl.marginHeight = 0; + g2ButtonComp.setLayout(gl); + + // now a button below the phone number + mCallButton = new Button(g2ButtonComp, SWT.PUSH); + mCallButton.setText("Call"); + mCallButton.setEnabled(false); + mCallButton.addSelectionListener(new SelectionAdapter() { + @Override + public void widgetSelected(SelectionEvent e) { + if (mEmulatorConsole != null) { + if (mVoiceButton.getSelection()) { + processCommandResult(mEmulatorConsole.call(mPhoneNumber.getText().trim())); + } else { + // we need to encode the message. We need to replace the carriage return + // character by the 2 character string \n. + // Because of this the \ character needs to be escaped as well. + // ReplaceAll() expects regexp so \ char are escaped twice. + String message = mSmsMessage.getText(); + message = message.replaceAll("\\\\", //$NON-NLS-1$ + "\\\\\\\\"); //$NON-NLS-1$ + + // While the normal line delimiter is returned by Text.getLineDelimiter() + // it seems copy pasting text coming from somewhere else could have another + // delimited. For this reason, we'll replace is several steps + + // replace the dual CR-LF + message = message.replaceAll("\r\n", "\\\\n"); //$NON-NLS-1$ //$NON-NLS-2$ + + // replace remaining stand alone \n + message = message.replaceAll("\n", "\\\\n"); //$NON-NLS-1$ //$NON-NLS-2$ + + // replace remaining stand alone \r + message = message.replaceAll("\r", "\\\\n"); //$NON-NLS-1$ //$NON-NLS-2$ + + processCommandResult(mEmulatorConsole.sendSms(mPhoneNumber.getText().trim(), + message)); + } + } + } + }); + + mCancelButton = new Button(g2ButtonComp, SWT.PUSH); + mCancelButton.setText("Hang Up"); + mCancelButton.setEnabled(false); + mCancelButton.addSelectionListener(new SelectionAdapter() { + @Override + public void widgetSelected(SelectionEvent e) { + if (mEmulatorConsole != null) { + if (mVoiceButton.getSelection()) { + processCommandResult(mEmulatorConsole.cancelCall( + mPhoneNumber.getText().trim())); + } + } + } + }); + } + + /** + * Create Location controls. + * @param top + */ + private void createLocationControls(final Composite top) { + Label l = new Label(top, SWT.NONE); + l.setLayoutData(new GridData(GridData.FILL_HORIZONTAL)); + l.setText("Location Controls"); + + mLocationFolders = new TabFolder(top, SWT.NONE); + mLocationFolders.setLayoutData(new GridData(GridData.FILL_HORIZONTAL)); + + Composite manualLocationComp = new Composite(mLocationFolders, SWT.NONE); + TabItem item = new TabItem(mLocationFolders, SWT.NONE); + item.setText("Manual"); + item.setControl(manualLocationComp); + + createManualLocationControl(manualLocationComp); + + ImageLoader loader = ImageLoader.getDdmUiLibLoader(); + mPlayImage = loader.loadImage("play.png", mParent.getDisplay()); //$NON-NLS-1$ + mPauseImage = loader.loadImage("pause.png", mParent.getDisplay()); //$NON-NLS-1$ + + Composite gpxLocationComp = new Composite(mLocationFolders, SWT.NONE); + item = new TabItem(mLocationFolders, SWT.NONE); + item.setText("GPX"); + item.setControl(gpxLocationComp); + + createGpxLocationControl(gpxLocationComp); + + Composite kmlLocationComp = new Composite(mLocationFolders, SWT.NONE); + kmlLocationComp.setLayout(new FillLayout()); + item = new TabItem(mLocationFolders, SWT.NONE); + item.setText("KML"); + item.setControl(kmlLocationComp); + + createKmlLocationControl(kmlLocationComp); + } + + private void createManualLocationControl(Composite manualLocationComp) { + final StackLayout sl; + GridLayout gl; + Label label; + + manualLocationComp.setLayout(new GridLayout(1, false)); + mDecimalButton = new Button(manualLocationComp, SWT.RADIO); + mDecimalButton.setLayoutData(new GridData(GridData.FILL_HORIZONTAL)); + mDecimalButton.setText("Decimal"); + mSexagesimalButton = new Button(manualLocationComp, SWT.RADIO); + mSexagesimalButton.setLayoutData(new GridData(GridData.FILL_HORIZONTAL)); + mSexagesimalButton.setText("Sexagesimal"); + + // composite to hold and switching between the 2 modes. + final Composite content = new Composite(manualLocationComp, SWT.NONE); + content.setLayout(sl = new StackLayout()); + + // decimal display + final Composite decimalContent = new Composite(content, SWT.NONE); + decimalContent.setLayout(gl = new GridLayout(2, false)); + gl.marginHeight = gl.marginWidth = 0; + + mLongitudeControls = new CoordinateControls(); + mLatitudeControls = new CoordinateControls(); + + label = new Label(decimalContent, SWT.NONE); + label.setText("Longitude"); + + mLongitudeControls.createDecimalText(decimalContent); + + label = new Label(decimalContent, SWT.NONE); + label.setText("Latitude"); + + mLatitudeControls.createDecimalText(decimalContent); + + // sexagesimal content + final Composite sexagesimalContent = new Composite(content, SWT.NONE); + sexagesimalContent.setLayout(gl = new GridLayout(7, false)); + gl.marginHeight = gl.marginWidth = 0; + + label = new Label(sexagesimalContent, SWT.NONE); + label.setText("Longitude"); + + mLongitudeControls.createSexagesimalDegreeText(sexagesimalContent); + + label = new Label(sexagesimalContent, SWT.NONE); + label.setText("\u00B0"); // degree character + + mLongitudeControls.createSexagesimalMinuteText(sexagesimalContent); + + label = new Label(sexagesimalContent, SWT.NONE); + label.setText("'"); + + mLongitudeControls.createSexagesimalSecondText(sexagesimalContent); + + label = new Label(sexagesimalContent, SWT.NONE); + label.setText("\""); + + label = new Label(sexagesimalContent, SWT.NONE); + label.setText("Latitude"); + + mLatitudeControls.createSexagesimalDegreeText(sexagesimalContent); + + label = new Label(sexagesimalContent, SWT.NONE); + label.setText("\u00B0"); + + mLatitudeControls.createSexagesimalMinuteText(sexagesimalContent); + + label = new Label(sexagesimalContent, SWT.NONE); + label.setText("'"); + + mLatitudeControls.createSexagesimalSecondText(sexagesimalContent); + + label = new Label(sexagesimalContent, SWT.NONE); + label.setText("\""); + + // set the default display to decimal + sl.topControl = decimalContent; + mDecimalButton.setSelection(true); + + mDecimalButton.addSelectionListener(new SelectionAdapter() { + @Override + public void widgetSelected(SelectionEvent e) { + if (mDecimalButton.getSelection()) { + sl.topControl = decimalContent; + } else { + sl.topControl = sexagesimalContent; + } + content.layout(); + } + }); + + Button sendButton = new Button(manualLocationComp, SWT.PUSH); + sendButton.setText("Send"); + sendButton.addSelectionListener(new SelectionAdapter() { + @Override + public void widgetSelected(SelectionEvent e) { + if (mEmulatorConsole != null) { + processCommandResult(mEmulatorConsole.sendLocation( + mLongitudeControls.getValue(), mLatitudeControls.getValue(), 0)); + } + } + }); + + mLongitudeControls.setValue(DEFAULT_LONGITUDE); + mLatitudeControls.setValue(DEFAULT_LATITUDE); + } + + private void createGpxLocationControl(Composite gpxLocationComp) { + GridData gd; + + IPreferenceStore store = DdmUiPreferences.getStore(); + + gpxLocationComp.setLayout(new GridLayout(1, false)); + + mGpxUploadButton = new Button(gpxLocationComp, SWT.PUSH); + mGpxUploadButton.setText("Load GPX..."); + + // Table for way point + mGpxWayPointTable = new Table(gpxLocationComp, + SWT.V_SCROLL | SWT.H_SCROLL | SWT.FULL_SELECTION); + mGpxWayPointTable.setLayoutData(gd = new GridData(GridData.FILL_HORIZONTAL)); + gd.heightHint = 100; + mGpxWayPointTable.setHeaderVisible(true); + mGpxWayPointTable.setLinesVisible(true); + + TableHelper.createTableColumn(mGpxWayPointTable, "Name", SWT.LEFT, + "Some Name", + PREFS_WAYPOINT_COL_NAME, store); + TableHelper.createTableColumn(mGpxWayPointTable, "Longitude", SWT.LEFT, + "-199.999999", + PREFS_WAYPOINT_COL_LONGITUDE, store); + TableHelper.createTableColumn(mGpxWayPointTable, "Latitude", SWT.LEFT, + "-199.999999", + PREFS_WAYPOINT_COL_LATITUDE, store); + TableHelper.createTableColumn(mGpxWayPointTable, "Elevation", SWT.LEFT, + "99999.9", + PREFS_WAYPOINT_COL_ELEVATION, store); + TableHelper.createTableColumn(mGpxWayPointTable, "Description", SWT.LEFT, + "Some Description", + PREFS_WAYPOINT_COL_DESCRIPTION, store); + + final TableViewer gpxWayPointViewer = new TableViewer(mGpxWayPointTable); + gpxWayPointViewer.setContentProvider(new WayPointContentProvider()); + gpxWayPointViewer.setLabelProvider(new WayPointLabelProvider()); + + gpxWayPointViewer.addSelectionChangedListener(new ISelectionChangedListener() { + @Override + public void selectionChanged(SelectionChangedEvent event) { + ISelection selection = event.getSelection(); + if (selection instanceof IStructuredSelection) { + IStructuredSelection structuredSelection = (IStructuredSelection)selection; + Object selectedObject = structuredSelection.getFirstElement(); + if (selectedObject instanceof WayPoint) { + WayPoint wayPoint = (WayPoint)selectedObject; + + if (mEmulatorConsole != null && mPlayingTrack == false) { + processCommandResult(mEmulatorConsole.sendLocation( + wayPoint.getLongitude(), wayPoint.getLatitude(), + wayPoint.getElevation())); + } + } + } + } + }); + + // table for tracks. + mGpxTrackTable = new Table(gpxLocationComp, + SWT.V_SCROLL | SWT.H_SCROLL | SWT.FULL_SELECTION); + mGpxTrackTable.setLayoutData(gd = new GridData(GridData.FILL_HORIZONTAL)); + gd.heightHint = 100; + mGpxTrackTable.setHeaderVisible(true); + mGpxTrackTable.setLinesVisible(true); + + TableHelper.createTableColumn(mGpxTrackTable, "Name", SWT.LEFT, + "Some very long name", + PREFS_TRACK_COL_NAME, store); + TableHelper.createTableColumn(mGpxTrackTable, "Point Count", SWT.RIGHT, + "9999", + PREFS_TRACK_COL_COUNT, store); + TableHelper.createTableColumn(mGpxTrackTable, "First Point Time", SWT.LEFT, + "999-99-99T99:99:99Z", + PREFS_TRACK_COL_FIRST, store); + TableHelper.createTableColumn(mGpxTrackTable, "Last Point Time", SWT.LEFT, + "999-99-99T99:99:99Z", + PREFS_TRACK_COL_LAST, store); + TableHelper.createTableColumn(mGpxTrackTable, "Comment", SWT.LEFT, + "-199.999999", + PREFS_TRACK_COL_COMMENT, store); + + final TableViewer gpxTrackViewer = new TableViewer(mGpxTrackTable); + gpxTrackViewer.setContentProvider(new TrackContentProvider()); + gpxTrackViewer.setLabelProvider(new TrackLabelProvider()); + + gpxTrackViewer.addSelectionChangedListener(new ISelectionChangedListener() { + @Override + public void selectionChanged(SelectionChangedEvent event) { + ISelection selection = event.getSelection(); + if (selection instanceof IStructuredSelection) { + IStructuredSelection structuredSelection = (IStructuredSelection)selection; + Object selectedObject = structuredSelection.getFirstElement(); + if (selectedObject instanceof Track) { + Track track = (Track)selectedObject; + + if (mEmulatorConsole != null && mPlayingTrack == false) { + TrackPoint[] points = track.getPoints(); + processCommandResult(mEmulatorConsole.sendLocation( + points[0].getLongitude(), points[0].getLatitude(), + points[0].getElevation())); + } + + mPlayGpxButton.setEnabled(true); + mGpxBackwardButton.setEnabled(true); + mGpxForwardButton.setEnabled(true); + mGpxSpeedButton.setEnabled(true); + + return; + } + } + + mPlayGpxButton.setEnabled(false); + mGpxBackwardButton.setEnabled(false); + mGpxForwardButton.setEnabled(false); + mGpxSpeedButton.setEnabled(false); + } + }); + + mGpxUploadButton.addSelectionListener(new SelectionAdapter() { + @Override + public void widgetSelected(SelectionEvent e) { + FileDialog fileDialog = new FileDialog(mParent.getShell(), SWT.OPEN); + + fileDialog.setText("Load GPX File"); + fileDialog.setFilterExtensions(new String[] { "*.gpx" } ); + + String fileName = fileDialog.open(); + if (fileName != null) { + GpxParser parser = new GpxParser(fileName); + if (parser.parse()) { + gpxWayPointViewer.setInput(parser.getWayPoints()); + gpxTrackViewer.setInput(parser.getTracks()); + } + } + } + }); + + mGpxPlayControls = new Composite(gpxLocationComp, SWT.NONE); + GridLayout gl; + mGpxPlayControls.setLayout(gl = new GridLayout(5, false)); + gl.marginHeight = gl.marginWidth = 0; + mGpxPlayControls.setLayoutData(new GridData(GridData.FILL_HORIZONTAL)); + + mPlayGpxButton = new Button(mGpxPlayControls, SWT.PUSH | SWT.FLAT); + mPlayGpxButton.setImage(mPlayImage); + mPlayGpxButton.addSelectionListener(new SelectionAdapter() { + @Override + public void widgetSelected(SelectionEvent e) { + if (mPlayingTrack == false) { + ISelection selection = gpxTrackViewer.getSelection(); + if (selection.isEmpty() == false && selection instanceof IStructuredSelection) { + IStructuredSelection structuredSelection = (IStructuredSelection)selection; + Object selectedObject = structuredSelection.getFirstElement(); + if (selectedObject instanceof Track) { + Track track = (Track)selectedObject; + playTrack(track); + } + } + } else { + // if we're playing, then we pause + mPlayingTrack = false; + if (mPlayingThread != null) { + mPlayingThread.interrupt(); + } + } + } + }); + + Label separator = new Label(mGpxPlayControls, SWT.SEPARATOR | SWT.VERTICAL); + separator.setLayoutData(gd = new GridData( + GridData.VERTICAL_ALIGN_FILL | GridData.GRAB_VERTICAL)); + gd.heightHint = 0; + + ImageLoader loader = ImageLoader.getDdmUiLibLoader(); + mGpxBackwardButton = new Button(mGpxPlayControls, SWT.TOGGLE | SWT.FLAT); + mGpxBackwardButton.setImage(loader.loadImage("backward.png", mParent.getDisplay())); //$NON-NLS-1$ + mGpxBackwardButton.setSelection(false); + mGpxBackwardButton.addSelectionListener(mDirectionButtonAdapter); + mGpxForwardButton = new Button(mGpxPlayControls, SWT.TOGGLE | SWT.FLAT); + mGpxForwardButton.setImage(loader.loadImage("forward.png", mParent.getDisplay())); //$NON-NLS-1$ + mGpxForwardButton.setSelection(true); + mGpxForwardButton.addSelectionListener(mDirectionButtonAdapter); + + mGpxSpeedButton = new Button(mGpxPlayControls, SWT.PUSH | SWT.FLAT); + + mSpeedIndex = 0; + mSpeed = PLAY_SPEEDS[mSpeedIndex]; + + mGpxSpeedButton.setText(String.format(SPEED_FORMAT, mSpeed)); + mGpxSpeedButton.addSelectionListener(mSpeedButtonAdapter); + + mPlayGpxButton.setEnabled(false); + mGpxBackwardButton.setEnabled(false); + mGpxForwardButton.setEnabled(false); + mGpxSpeedButton.setEnabled(false); + + } + + private void createKmlLocationControl(Composite kmlLocationComp) { + GridData gd; + + IPreferenceStore store = DdmUiPreferences.getStore(); + + kmlLocationComp.setLayout(new GridLayout(1, false)); + + mKmlUploadButton = new Button(kmlLocationComp, SWT.PUSH); + mKmlUploadButton.setText("Load KML..."); + + // Table for way point + mKmlWayPointTable = new Table(kmlLocationComp, + SWT.V_SCROLL | SWT.H_SCROLL | SWT.FULL_SELECTION); + mKmlWayPointTable.setLayoutData(gd = new GridData(GridData.FILL_HORIZONTAL)); + gd.heightHint = 200; + mKmlWayPointTable.setHeaderVisible(true); + mKmlWayPointTable.setLinesVisible(true); + + TableHelper.createTableColumn(mKmlWayPointTable, "Name", SWT.LEFT, + "Some Name", + PREFS_WAYPOINT_COL_NAME, store); + TableHelper.createTableColumn(mKmlWayPointTable, "Longitude", SWT.LEFT, + "-199.999999", + PREFS_WAYPOINT_COL_LONGITUDE, store); + TableHelper.createTableColumn(mKmlWayPointTable, "Latitude", SWT.LEFT, + "-199.999999", + PREFS_WAYPOINT_COL_LATITUDE, store); + TableHelper.createTableColumn(mKmlWayPointTable, "Elevation", SWT.LEFT, + "99999.9", + PREFS_WAYPOINT_COL_ELEVATION, store); + TableHelper.createTableColumn(mKmlWayPointTable, "Description", SWT.LEFT, + "Some Description", + PREFS_WAYPOINT_COL_DESCRIPTION, store); + + final TableViewer kmlWayPointViewer = new TableViewer(mKmlWayPointTable); + kmlWayPointViewer.setContentProvider(new WayPointContentProvider()); + kmlWayPointViewer.setLabelProvider(new WayPointLabelProvider()); + + mKmlUploadButton.addSelectionListener(new SelectionAdapter() { + @Override + public void widgetSelected(SelectionEvent e) { + FileDialog fileDialog = new FileDialog(mParent.getShell(), SWT.OPEN); + + fileDialog.setText("Load KML File"); + fileDialog.setFilterExtensions(new String[] { "*.kml" } ); + + String fileName = fileDialog.open(); + if (fileName != null) { + KmlParser parser = new KmlParser(fileName); + if (parser.parse()) { + kmlWayPointViewer.setInput(parser.getWayPoints()); + + mPlayKmlButton.setEnabled(true); + mKmlBackwardButton.setEnabled(true); + mKmlForwardButton.setEnabled(true); + mKmlSpeedButton.setEnabled(true); + } + } + } + }); + + kmlWayPointViewer.addSelectionChangedListener(new ISelectionChangedListener() { + @Override + public void selectionChanged(SelectionChangedEvent event) { + ISelection selection = event.getSelection(); + if (selection instanceof IStructuredSelection) { + IStructuredSelection structuredSelection = (IStructuredSelection)selection; + Object selectedObject = structuredSelection.getFirstElement(); + if (selectedObject instanceof WayPoint) { + WayPoint wayPoint = (WayPoint)selectedObject; + + if (mEmulatorConsole != null && mPlayingTrack == false) { + processCommandResult(mEmulatorConsole.sendLocation( + wayPoint.getLongitude(), wayPoint.getLatitude(), + wayPoint.getElevation())); + } + } + } + } + }); + + + + mKmlPlayControls = new Composite(kmlLocationComp, SWT.NONE); + GridLayout gl; + mKmlPlayControls.setLayout(gl = new GridLayout(5, false)); + gl.marginHeight = gl.marginWidth = 0; + mKmlPlayControls.setLayoutData(new GridData(GridData.FILL_HORIZONTAL)); + + mPlayKmlButton = new Button(mKmlPlayControls, SWT.PUSH | SWT.FLAT); + mPlayKmlButton.setImage(mPlayImage); + mPlayKmlButton.addSelectionListener(new SelectionAdapter() { + @Override + public void widgetSelected(SelectionEvent e) { + if (mPlayingTrack == false) { + Object input = kmlWayPointViewer.getInput(); + if (input instanceof WayPoint[]) { + playKml((WayPoint[])input); + } + } else { + // if we're playing, then we pause + mPlayingTrack = false; + if (mPlayingThread != null) { + mPlayingThread.interrupt(); + } + } + } + }); + + Label separator = new Label(mKmlPlayControls, SWT.SEPARATOR | SWT.VERTICAL); + separator.setLayoutData(gd = new GridData( + GridData.VERTICAL_ALIGN_FILL | GridData.GRAB_VERTICAL)); + gd.heightHint = 0; + + ImageLoader loader = ImageLoader.getDdmUiLibLoader(); + mKmlBackwardButton = new Button(mKmlPlayControls, SWT.TOGGLE | SWT.FLAT); + mKmlBackwardButton.setImage(loader.loadImage("backward.png", mParent.getDisplay())); //$NON-NLS-1$ + mKmlBackwardButton.setSelection(false); + mKmlBackwardButton.addSelectionListener(mDirectionButtonAdapter); + mKmlForwardButton = new Button(mKmlPlayControls, SWT.TOGGLE | SWT.FLAT); + mKmlForwardButton.setImage(loader.loadImage("forward.png", mParent.getDisplay())); //$NON-NLS-1$ + mKmlForwardButton.setSelection(true); + mKmlForwardButton.addSelectionListener(mDirectionButtonAdapter); + + mKmlSpeedButton = new Button(mKmlPlayControls, SWT.PUSH | SWT.FLAT); + + mSpeedIndex = 0; + mSpeed = PLAY_SPEEDS[mSpeedIndex]; + + mKmlSpeedButton.setText(String.format(SPEED_FORMAT, mSpeed)); + mKmlSpeedButton.addSelectionListener(mSpeedButtonAdapter); + + mPlayKmlButton.setEnabled(false); + mKmlBackwardButton.setEnabled(false); + mKmlForwardButton.setEnabled(false); + mKmlSpeedButton.setEnabled(false); + } + + /** + * Sets the focus to the proper control inside the panel. + */ + @Override + public void setFocus() { + } + + @Override + protected void postCreation() { + // pass + } + + private synchronized void setDataMode(int selectionIndex) { + if (mEmulatorConsole != null) { + processCommandResult(mEmulatorConsole.setGsmDataMode( + GsmMode.getEnum(GSM_MODES[selectionIndex][1]))); + } + } + + private synchronized void setVoiceMode(int selectionIndex) { + if (mEmulatorConsole != null) { + processCommandResult(mEmulatorConsole.setGsmVoiceMode( + GsmMode.getEnum(GSM_MODES[selectionIndex][1]))); + } + } + + private synchronized void setNetworkLatency(int selectionIndex) { + if (mEmulatorConsole != null) { + processCommandResult(mEmulatorConsole.setNetworkLatency(selectionIndex)); + } + } + + private synchronized void setNetworkSpeed(int selectionIndex) { + if (mEmulatorConsole != null) { + processCommandResult(mEmulatorConsole.setNetworkSpeed(selectionIndex)); + } + } + + + /** + * Callback on device selection change. + * @param device the new selected device + */ + public void handleNewDevice(IDevice device) { + if (mParent.isDisposed()) { + return; + } + // unlink to previous console. + synchronized (this) { + mEmulatorConsole = null; + } + + try { + // get the emulator console for this device + // First we need the device itself + if (device != null) { + GsmStatus gsm = null; + NetworkStatus netstatus = null; + + synchronized (this) { + mEmulatorConsole = EmulatorConsole.getConsole(device); + if (mEmulatorConsole != null) { + // get the gsm status + gsm = mEmulatorConsole.getGsmStatus(); + netstatus = mEmulatorConsole.getNetworkStatus(); + + if (gsm == null || netstatus == null) { + mEmulatorConsole = null; + } + } + } + + if (gsm != null && netstatus != null) { + Display d = mParent.getDisplay(); + if (d.isDisposed() == false) { + final GsmStatus f_gsm = gsm; + final NetworkStatus f_netstatus = netstatus; + + d.asyncExec(new Runnable() { + @Override + public void run() { + if (f_gsm.voice != GsmMode.UNKNOWN) { + mVoiceMode.select(getGsmComboIndex(f_gsm.voice)); + } else { + mVoiceMode.clearSelection(); + } + if (f_gsm.data != GsmMode.UNKNOWN) { + mDataMode.select(getGsmComboIndex(f_gsm.data)); + } else { + mDataMode.clearSelection(); + } + + if (f_netstatus.speed != -1) { + mNetworkSpeed.select(f_netstatus.speed); + } else { + mNetworkSpeed.clearSelection(); + } + + if (f_netstatus.latency != -1) { + mNetworkLatency.select(f_netstatus.latency); + } else { + mNetworkLatency.clearSelection(); + } + } + }); + } + } + } + } finally { + // enable/disable the ui + boolean enable = false; + synchronized (this) { + enable = mEmulatorConsole != null; + } + + enable(enable); + } + } + + /** + * Enable or disable the ui. Can be called from non ui threads. + * @param enabled + */ + private void enable(final boolean enabled) { + try { + Display d = mParent.getDisplay(); + d.asyncExec(new Runnable() { + @Override + public void run() { + if (mParent.isDisposed() == false) { + doEnable(enabled); + } + } + }); + } catch (SWTException e) { + // disposed. do nothing + } + } + + private boolean isValidPhoneNumber() { + String number = mPhoneNumber.getText().trim(); + + return number.matches(RE_PHONE_NUMBER); + } + + /** + * Enable or disable the ui. Cannot be called from non ui threads. + * @param enabled + */ + protected void doEnable(boolean enabled) { + mVoiceLabel.setEnabled(enabled); + mVoiceMode.setEnabled(enabled); + + mDataLabel.setEnabled(enabled); + mDataMode.setEnabled(enabled); + + mSpeedLabel.setEnabled(enabled); + mNetworkSpeed.setEnabled(enabled); + + mLatencyLabel.setEnabled(enabled); + mNetworkLatency.setEnabled(enabled); + + // Calling setEnabled on a text field will trigger a modifyText event, so we don't do it + // if we don't need to. + if (mPhoneNumber.isEnabled() != enabled) { + mNumberLabel.setEnabled(enabled); + mPhoneNumber.setEnabled(enabled); + } + + boolean valid = isValidPhoneNumber(); + + mVoiceButton.setEnabled(enabled && valid); + mSmsButton.setEnabled(enabled && valid); + + boolean smsValid = enabled && valid && mSmsButton.getSelection(); + + // Calling setEnabled on a text field will trigger a modifyText event, so we don't do it + // if we don't need to. + if (mSmsMessage.isEnabled() != smsValid) { + mMessageLabel.setEnabled(smsValid); + mSmsMessage.setEnabled(smsValid); + } + if (enabled == false) { + mSmsMessage.setText(""); //$NON-NLs-1$ + } + + mCallButton.setEnabled(enabled && valid); + mCancelButton.setEnabled(enabled && valid && mVoiceButton.getSelection()); + + if (enabled == false) { + mVoiceMode.clearSelection(); + mDataMode.clearSelection(); + mNetworkSpeed.clearSelection(); + mNetworkLatency.clearSelection(); + if (mPhoneNumber.getText().length() > 0) { + mPhoneNumber.setText(""); //$NON-NLS-1$ + } + } + + // location controls + mLocationFolders.setEnabled(enabled); + + mDecimalButton.setEnabled(enabled); + mSexagesimalButton.setEnabled(enabled); + mLongitudeControls.setEnabled(enabled); + mLatitudeControls.setEnabled(enabled); + + mGpxUploadButton.setEnabled(enabled); + mGpxWayPointTable.setEnabled(enabled); + mGpxTrackTable.setEnabled(enabled); + mKmlUploadButton.setEnabled(enabled); + mKmlWayPointTable.setEnabled(enabled); + } + + /** + * Returns the index of the combo item matching a specific GsmMode. + * @param mode + */ + private int getGsmComboIndex(GsmMode mode) { + for (int i = 0 ; i < GSM_MODES.length; i++) { + String[] modes = GSM_MODES[i]; + if (mode.getTag().equals(modes[1])) { + return i; + } + } + return -1; + } + + /** + * Processes the result of a command sent to the console. + * @param result the result of the command. + */ + private boolean processCommandResult(final String result) { + if (result != EmulatorConsole.RESULT_OK) { + try { + mParent.getDisplay().asyncExec(new Runnable() { + @Override + public void run() { + if (mParent.isDisposed() == false) { + MessageDialog.openError(mParent.getShell(), "Emulator Console", + result); + } + } + }); + } catch (SWTException e) { + // we're quitting, just ignore + } + + return false; + } + + return true; + } + + /** + * @param track + */ + private void playTrack(final Track track) { + // no need to synchronize this check, the worst that can happen, is we start the thread + // for nothing. + if (mEmulatorConsole != null) { + mPlayGpxButton.setImage(mPauseImage); + mPlayKmlButton.setImage(mPauseImage); + mPlayingTrack = true; + + mPlayingThread = new Thread() { + @Override + public void run() { + try { + TrackPoint[] trackPoints = track.getPoints(); + int count = trackPoints.length; + + // get the start index. + int start = 0; + if (mPlayDirection == -1) { + start = count - 1; + } + + for (int p = start; p >= 0 && p < count; p += mPlayDirection) { + if (mPlayingTrack == false) { + return; + } + + // get the current point and send its location to + // the emulator. + final TrackPoint trackPoint = trackPoints[p]; + + synchronized (EmulatorControlPanel.this) { + if (mEmulatorConsole == null || + processCommandResult(mEmulatorConsole.sendLocation( + trackPoint.getLongitude(), trackPoint.getLatitude(), + trackPoint.getElevation())) == false) { + return; + } + } + + // if this is not the final point, then get the next one and + // compute the delta time + int nextIndex = p + mPlayDirection; + if (nextIndex >=0 && nextIndex < count) { + TrackPoint nextPoint = trackPoints[nextIndex]; + + long delta = nextPoint.getTime() - trackPoint.getTime(); + if (delta < 0) { + delta = -delta; + } + + long startTime = System.currentTimeMillis(); + + try { + sleep(delta / mSpeed); + } catch (InterruptedException e) { + if (mPlayingTrack == false) { + return; + } + + // we got interrupted, lets make sure we can play + do { + long waited = System.currentTimeMillis() - startTime; + long needToWait = delta / mSpeed; + if (waited < needToWait) { + try { + sleep(needToWait - waited); + } catch (InterruptedException e1) { + // we'll just loop and wait again if needed. + // unless we're supposed to stop + if (mPlayingTrack == false) { + return; + } + } + } else { + break; + } + } while (true); + } + } + } + } finally { + mPlayingTrack = false; + try { + mParent.getDisplay().asyncExec(new Runnable() { + @Override + public void run() { + if (mPlayGpxButton.isDisposed() == false) { + mPlayGpxButton.setImage(mPlayImage); + mPlayKmlButton.setImage(mPlayImage); + } + } + }); + } catch (SWTException e) { + // we're quitting, just ignore + } + } + } + }; + + mPlayingThread.start(); + } + } + + private void playKml(final WayPoint[] trackPoints) { + // no need to synchronize this check, the worst that can happen, is we start the thread + // for nothing. + if (mEmulatorConsole != null) { + mPlayGpxButton.setImage(mPauseImage); + mPlayKmlButton.setImage(mPauseImage); + mPlayingTrack = true; + + mPlayingThread = new Thread() { + @Override + public void run() { + try { + int count = trackPoints.length; + + // get the start index. + int start = 0; + if (mPlayDirection == -1) { + start = count - 1; + } + + for (int p = start; p >= 0 && p < count; p += mPlayDirection) { + if (mPlayingTrack == false) { + return; + } + + // get the current point and send its location to + // the emulator. + WayPoint trackPoint = trackPoints[p]; + + synchronized (EmulatorControlPanel.this) { + if (mEmulatorConsole == null || + processCommandResult(mEmulatorConsole.sendLocation( + trackPoint.getLongitude(), trackPoint.getLatitude(), + trackPoint.getElevation())) == false) { + return; + } + } + + // if this is not the final point, then get the next one and + // compute the delta time + int nextIndex = p + mPlayDirection; + if (nextIndex >=0 && nextIndex < count) { + + long delta = 1000; // 1 second + if (delta < 0) { + delta = -delta; + } + + long startTime = System.currentTimeMillis(); + + try { + sleep(delta / mSpeed); + } catch (InterruptedException e) { + if (mPlayingTrack == false) { + return; + } + + // we got interrupted, lets make sure we can play + do { + long waited = System.currentTimeMillis() - startTime; + long needToWait = delta / mSpeed; + if (waited < needToWait) { + try { + sleep(needToWait - waited); + } catch (InterruptedException e1) { + // we'll just loop and wait again if needed. + // unless we're supposed to stop + if (mPlayingTrack == false) { + return; + } + } + } else { + break; + } + } while (true); + } + } + } + } finally { + mPlayingTrack = false; + try { + mParent.getDisplay().asyncExec(new Runnable() { + @Override + public void run() { + if (mPlayGpxButton.isDisposed() == false) { + mPlayGpxButton.setImage(mPlayImage); + mPlayKmlButton.setImage(mPlayImage); + } + } + }); + } catch (SWTException e) { + // we're quitting, just ignore + } + } + } + }; + + mPlayingThread.start(); + } + } +} diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/HeapPanel.java b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/HeapPanel.java new file mode 100644 index 00000000..d0af8b08 --- /dev/null +++ b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/HeapPanel.java @@ -0,0 +1,1310 @@ +/* + * Copyright (C) 2007 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.ddmuilib; + +import com.android.ddmlib.AndroidDebugBridge.IClientChangeListener; +import com.android.ddmlib.Client; +import com.android.ddmlib.ClientData; +import com.android.ddmlib.HeapSegment.HeapSegmentElement; +import com.android.ddmlib.Log; + +import org.eclipse.jface.preference.IPreferenceStore; +import org.eclipse.swt.SWT; +import org.eclipse.swt.SWTException; +import org.eclipse.swt.custom.StackLayout; +import org.eclipse.swt.events.SelectionAdapter; +import org.eclipse.swt.events.SelectionEvent; +import org.eclipse.swt.graphics.Color; +import org.eclipse.swt.graphics.Font; +import org.eclipse.swt.graphics.FontData; +import org.eclipse.swt.graphics.GC; +import org.eclipse.swt.graphics.Image; +import org.eclipse.swt.graphics.ImageData; +import org.eclipse.swt.graphics.PaletteData; +import org.eclipse.swt.graphics.Point; +import org.eclipse.swt.graphics.RGB; +import org.eclipse.swt.layout.GridData; +import org.eclipse.swt.layout.GridLayout; +import org.eclipse.swt.widgets.Button; +import org.eclipse.swt.widgets.Combo; +import org.eclipse.swt.widgets.Composite; +import org.eclipse.swt.widgets.Control; +import org.eclipse.swt.widgets.Display; +import org.eclipse.swt.widgets.Group; +import org.eclipse.swt.widgets.Label; +import org.eclipse.swt.widgets.Table; +import org.eclipse.swt.widgets.TableColumn; +import org.eclipse.swt.widgets.TableItem; +import org.jfree.chart.ChartFactory; +import org.jfree.chart.JFreeChart; +import org.jfree.chart.axis.CategoryAxis; +import org.jfree.chart.axis.CategoryLabelPositions; +import org.jfree.chart.labels.CategoryToolTipGenerator; +import org.jfree.chart.plot.CategoryPlot; +import org.jfree.chart.plot.Plot; +import org.jfree.chart.plot.PlotOrientation; +import org.jfree.chart.renderer.category.CategoryItemRenderer; +import org.jfree.chart.title.TextTitle; +import org.jfree.data.category.CategoryDataset; +import org.jfree.data.category.DefaultCategoryDataset; +import org.jfree.experimental.chart.swt.ChartComposite; +import org.jfree.experimental.swt.SWTUtils; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.text.NumberFormat; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.Map; +import java.util.Set; + + +/** + * Base class for our information panels. + */ +public final class HeapPanel extends BaseHeapPanel { + private static final String PREFS_STATS_COL_TYPE = "heapPanel.col0"; //$NON-NLS-1$ + private static final String PREFS_STATS_COL_COUNT = "heapPanel.col1"; //$NON-NLS-1$ + private static final String PREFS_STATS_COL_SIZE = "heapPanel.col2"; //$NON-NLS-1$ + private static final String PREFS_STATS_COL_SMALLEST = "heapPanel.col3"; //$NON-NLS-1$ + private static final String PREFS_STATS_COL_LARGEST = "heapPanel.col4"; //$NON-NLS-1$ + private static final String PREFS_STATS_COL_MEDIAN = "heapPanel.col5"; //$NON-NLS-1$ + private static final String PREFS_STATS_COL_AVERAGE = "heapPanel.col6"; //$NON-NLS-1$ + + /* args to setUpdateStatus() */ + private static final int NOT_SELECTED = 0; + private static final int NOT_ENABLED = 1; + private static final int ENABLED = 2; + + /** color palette and map legend. NATIVE is the last enum is a 0 based enum list, so we need + * Native+1 at least. We also need 2 more entries for free area and expansion area. */ + private static final int NUM_PALETTE_ENTRIES = HeapSegmentElement.KIND_NATIVE+2 +1; + private static final String[] mMapLegend = new String[NUM_PALETTE_ENTRIES]; + private static final PaletteData mMapPalette = createPalette(); + + private static final boolean DISPLAY_HEAP_BITMAP = false; + private static final boolean DISPLAY_HILBERT_BITMAP = false; + + private static final int PLACEHOLDER_HILBERT_SIZE = 200; + private static final int PLACEHOLDER_LINEAR_V_SIZE = 100; + private static final int PLACEHOLDER_LINEAR_H_SIZE = 300; + + private static final int[] ZOOMS = {100, 50, 25}; + + private static final NumberFormat sByteFormatter = NumberFormat.getInstance(); + private static final NumberFormat sLargeByteFormatter = NumberFormat.getInstance(); + private static final NumberFormat sCountFormatter = NumberFormat.getInstance(); + + static { + sByteFormatter.setMinimumFractionDigits(0); + sByteFormatter.setMaximumFractionDigits(1); + sLargeByteFormatter.setMinimumFractionDigits(3); + sLargeByteFormatter.setMaximumFractionDigits(3); + + sCountFormatter.setGroupingUsed(true); + } + + private Display mDisplay; + + private Composite mTop; // real top + private Label mUpdateStatus; + private Table mHeapSummary; + private Combo mDisplayMode; + + //private ScrolledComposite mScrolledComposite; + + private Composite mDisplayBase; // base of the displays. + private StackLayout mDisplayStack; + + private Composite mStatisticsBase; + private Table mStatisticsTable; + private JFreeChart mChart; + private ChartComposite mChartComposite; + private Button mGcButton; + private DefaultCategoryDataset mAllocCountDataSet; + + private Composite mLinearBase; + private Label mLinearHeapImage; + + private Composite mHilbertBase; + private Label mHilbertHeapImage; + private Group mLegend; + private Combo mZoom; + + /** Image used for the hilbert display. Since we recreate a new image every time, we + * keep this one around to dispose it. */ + private Image mHilbertImage; + private Image mLinearImage; + private Composite[] mLayout; + + /* + * Create color palette for map. Set up titles for legend. + */ + private static PaletteData createPalette() { + RGB colors[] = new RGB[NUM_PALETTE_ENTRIES]; + colors[0] + = new RGB(192, 192, 192); // non-heap pixels are gray + mMapLegend[0] + = "(heap expansion area)"; + + colors[1] + = new RGB(0, 0, 0); // free chunks are black + mMapLegend[1] + = "free"; + + colors[HeapSegmentElement.KIND_OBJECT + 2] + = new RGB(0, 0, 255); // objects are blue + mMapLegend[HeapSegmentElement.KIND_OBJECT + 2] + = "data object"; + + colors[HeapSegmentElement.KIND_CLASS_OBJECT + 2] + = new RGB(0, 255, 0); // class objects are green + mMapLegend[HeapSegmentElement.KIND_CLASS_OBJECT + 2] + = "class object"; + + colors[HeapSegmentElement.KIND_ARRAY_1 + 2] + = new RGB(255, 0, 0); // byte/bool arrays are red + mMapLegend[HeapSegmentElement.KIND_ARRAY_1 + 2] + = "1-byte array (byte[], boolean[])"; + + colors[HeapSegmentElement.KIND_ARRAY_2 + 2] + = new RGB(255, 128, 0); // short/char arrays are orange + mMapLegend[HeapSegmentElement.KIND_ARRAY_2 + 2] + = "2-byte array (short[], char[])"; + + colors[HeapSegmentElement.KIND_ARRAY_4 + 2] + = new RGB(255, 255, 0); // obj/int/float arrays are yellow + mMapLegend[HeapSegmentElement.KIND_ARRAY_4 + 2] + = "4-byte array (object[], int[], float[])"; + + colors[HeapSegmentElement.KIND_ARRAY_8 + 2] + = new RGB(255, 128, 128); // long/double arrays are pink + mMapLegend[HeapSegmentElement.KIND_ARRAY_8 + 2] + = "8-byte array (long[], double[])"; + + colors[HeapSegmentElement.KIND_UNKNOWN + 2] + = new RGB(255, 0, 255); // unknown objects are cyan + mMapLegend[HeapSegmentElement.KIND_UNKNOWN + 2] + = "unknown object"; + + colors[HeapSegmentElement.KIND_NATIVE + 2] + = new RGB(64, 64, 64); // native objects are dark gray + mMapLegend[HeapSegmentElement.KIND_NATIVE + 2] + = "non-Java object"; + + return new PaletteData(colors); + } + + /** + * Sent when an existing client information changed. + *

+ * This is sent from a non UI thread. + * @param client the updated client. + * @param changeMask the bit mask describing the changed properties. It can contain + * any of the following values: {@link Client#CHANGE_INFO}, {@link Client#CHANGE_NAME} + * {@link Client#CHANGE_DEBUGGER_STATUS}, {@link Client#CHANGE_THREAD_MODE}, + * {@link Client#CHANGE_THREAD_DATA}, {@link Client#CHANGE_HEAP_MODE}, + * {@link Client#CHANGE_HEAP_DATA}, {@link Client#CHANGE_NATIVE_HEAP_DATA} + * + * @see IClientChangeListener#clientChanged(Client, int) + */ + @Override + public void clientChanged(final Client client, int changeMask) { + if (client == getCurrentClient()) { + if ((changeMask & Client.CHANGE_HEAP_MODE) == Client.CHANGE_HEAP_MODE || + (changeMask & Client.CHANGE_HEAP_DATA) == Client.CHANGE_HEAP_DATA) { + try { + mTop.getDisplay().asyncExec(new Runnable() { + @Override + public void run() { + clientSelected(); + } + }); + } catch (SWTException e) { + // display is disposed (app is quitting most likely), we do nothing. + } + } + } + } + + /** + * Sent when a new device is selected. The new device can be accessed + * with {@link #getCurrentDevice()} + */ + @Override + public void deviceSelected() { + // pass + } + + /** + * Sent when a new client is selected. The new client can be accessed + * with {@link #getCurrentClient()}. + */ + @Override + public void clientSelected() { + if (mTop.isDisposed()) + return; + + Client client = getCurrentClient(); + + Log.d("ddms", "HeapPanel: changed " + client); + + if (client != null) { + ClientData cd = client.getClientData(); + + if (client.isHeapUpdateEnabled()) { + mGcButton.setEnabled(true); + mDisplayMode.setEnabled(true); + setUpdateStatus(ENABLED); + } else { + setUpdateStatus(NOT_ENABLED); + mGcButton.setEnabled(false); + mDisplayMode.setEnabled(false); + } + + fillSummaryTable(cd); + + int mode = mDisplayMode.getSelectionIndex(); + if (mode == 0) { + fillDetailedTable(client, false /* forceRedraw */); + } else { + if (DISPLAY_HEAP_BITMAP) { + renderHeapData(cd, mode - 1, false /* forceRedraw */); + } + } + } else { + mGcButton.setEnabled(false); + mDisplayMode.setEnabled(false); + fillSummaryTable(null); + fillDetailedTable(null, true); + setUpdateStatus(NOT_SELECTED); + } + + // sizes of things change frequently, so redo layout + //mScrolledComposite.setMinSize(mDisplayStack.topControl.computeSize(SWT.DEFAULT, + // SWT.DEFAULT)); + mDisplayBase.layout(); + //mScrolledComposite.redraw(); + } + + /** + * Create our control(s). + */ + @Override + protected Control createControl(Composite parent) { + mDisplay = parent.getDisplay(); + + GridLayout gl; + + mTop = new Composite(parent, SWT.NONE); + mTop.setLayout(new GridLayout(1, false)); + mTop.setLayoutData(new GridData(GridData.FILL_BOTH)); + + mUpdateStatus = new Label(mTop, SWT.NONE); + setUpdateStatus(NOT_SELECTED); + + Composite summarySection = new Composite(mTop, SWT.NONE); + summarySection.setLayout(gl = new GridLayout(2, false)); + gl.marginHeight = gl.marginWidth = 0; + + mHeapSummary = createSummaryTable(summarySection); + mGcButton = new Button(summarySection, SWT.PUSH); + mGcButton.setText("Cause GC"); + mGcButton.setEnabled(false); + mGcButton.addSelectionListener(new SelectionAdapter() { + @Override + public void widgetSelected(SelectionEvent e) { + Client client = getCurrentClient(); + if (client != null) { + client.executeGarbageCollector(); + } + } + }); + + Composite comboSection = new Composite(mTop, SWT.NONE); + gl = new GridLayout(2, false); + gl.marginHeight = gl.marginWidth = 0; + comboSection.setLayout(gl); + + Label displayLabel = new Label(comboSection, SWT.NONE); + displayLabel.setText("Display: "); + + mDisplayMode = new Combo(comboSection, SWT.READ_ONLY); + mDisplayMode.setEnabled(false); + mDisplayMode.add("Stats"); + if (DISPLAY_HEAP_BITMAP) { + mDisplayMode.add("Linear"); + if (DISPLAY_HILBERT_BITMAP) { + mDisplayMode.add("Hilbert"); + } + } + + // the base of the displays. + mDisplayBase = new Composite(mTop, SWT.NONE); + mDisplayBase.setLayoutData(new GridData(GridData.FILL_BOTH)); + mDisplayStack = new StackLayout(); + mDisplayBase.setLayout(mDisplayStack); + + // create the statistics display + mStatisticsBase = new Composite(mDisplayBase, SWT.NONE); + //mStatisticsBase.setLayoutData(new GridData(GridData.FILL_BOTH)); + mStatisticsBase.setLayout(gl = new GridLayout(1, false)); + gl.marginHeight = gl.marginWidth = 0; + mDisplayStack.topControl = mStatisticsBase; + + mStatisticsTable = createDetailedTable(mStatisticsBase); + mStatisticsTable.setLayoutData(new GridData(GridData.FILL_BOTH)); + + createChart(); + + //create the linear composite + mLinearBase = new Composite(mDisplayBase, SWT.NONE); + //mLinearBase.setLayoutData(new GridData()); + gl = new GridLayout(1, false); + gl.marginHeight = gl.marginWidth = 0; + mLinearBase.setLayout(gl); + + { + mLinearHeapImage = new Label(mLinearBase, SWT.NONE); + mLinearHeapImage.setLayoutData(new GridData()); + mLinearHeapImage.setImage(ImageLoader.createPlaceHolderArt(mDisplay, + PLACEHOLDER_LINEAR_H_SIZE, PLACEHOLDER_LINEAR_V_SIZE, + mDisplay.getSystemColor(SWT.COLOR_BLUE))); + + // create a composite to contain the bottom part (legend) + Composite bottomSection = new Composite(mLinearBase, SWT.NONE); + gl = new GridLayout(1, false); + gl.marginHeight = gl.marginWidth = 0; + bottomSection.setLayout(gl); + + createLegend(bottomSection); + } + +/* + mScrolledComposite = new ScrolledComposite(mTop, SWT.H_SCROLL | SWT.V_SCROLL); + mScrolledComposite.setLayoutData(new GridData(GridData.FILL_BOTH)); + mScrolledComposite.setExpandHorizontal(true); + mScrolledComposite.setExpandVertical(true); + mScrolledComposite.setContent(mDisplayBase); +*/ + + + // create the hilbert display. + mHilbertBase = new Composite(mDisplayBase, SWT.NONE); + //mHilbertBase.setLayoutData(new GridData()); + gl = new GridLayout(2, false); + gl.marginHeight = gl.marginWidth = 0; + mHilbertBase.setLayout(gl); + + if (DISPLAY_HILBERT_BITMAP) { + mHilbertHeapImage = new Label(mHilbertBase, SWT.NONE); + mHilbertHeapImage.setLayoutData(new GridData()); + mHilbertHeapImage.setImage(ImageLoader.createPlaceHolderArt(mDisplay, + PLACEHOLDER_HILBERT_SIZE, PLACEHOLDER_HILBERT_SIZE, + mDisplay.getSystemColor(SWT.COLOR_BLUE))); + + // create a composite to contain the right part (legend + zoom) + Composite rightSection = new Composite(mHilbertBase, SWT.NONE); + gl = new GridLayout(1, false); + gl.marginHeight = gl.marginWidth = 0; + rightSection.setLayout(gl); + + Composite zoomComposite = new Composite(rightSection, SWT.NONE); + gl = new GridLayout(2, false); + zoomComposite.setLayout(gl); + + Label l = new Label(zoomComposite, SWT.NONE); + l.setText("Zoom:"); + mZoom = new Combo(zoomComposite, SWT.READ_ONLY); + for (int z : ZOOMS) { + mZoom.add(String.format("%1$d%%", z)); //$NON-NLS-1$ + } + + mZoom.select(0); + mZoom.addSelectionListener(new SelectionAdapter() { + @Override + public void widgetSelected(SelectionEvent e) { + setLegendText(mZoom.getSelectionIndex()); + Client client = getCurrentClient(); + if (client != null) { + renderHeapData(client.getClientData(), 1, true); + mTop.pack(); + } + } + }); + + createLegend(rightSection); + } + mHilbertBase.pack(); + + mLayout = new Composite[] { mStatisticsBase, mLinearBase, mHilbertBase }; + mDisplayMode.select(0); + mDisplayMode.addSelectionListener(new SelectionAdapter() { + @Override + public void widgetSelected(SelectionEvent e) { + int index = mDisplayMode.getSelectionIndex(); + Client client = getCurrentClient(); + + if (client != null) { + if (index == 0) { + fillDetailedTable(client, true /* forceRedraw */); + } else { + renderHeapData(client.getClientData(), index-1, true /* forceRedraw */); + } + } + + mDisplayStack.topControl = mLayout[index]; + //mScrolledComposite.setMinSize(mDisplayStack.topControl.computeSize(SWT.DEFAULT, + // SWT.DEFAULT)); + mDisplayBase.layout(); + //mScrolledComposite.redraw(); + } + }); + + //mScrolledComposite.setMinSize(mDisplayStack.topControl.computeSize(SWT.DEFAULT, + // SWT.DEFAULT)); + mDisplayBase.layout(); + //mScrolledComposite.redraw(); + + return mTop; + } + + /** + * Sets the focus to the proper control inside the panel. + */ + @Override + public void setFocus() { + mHeapSummary.setFocus(); + } + + + private Table createSummaryTable(Composite base) { + Table tab = new Table(base, SWT.SINGLE | SWT.FULL_SELECTION); + tab.setHeaderVisible(true); + tab.setLinesVisible(true); + + TableColumn col; + + col = new TableColumn(tab, SWT.RIGHT); + col.setText("ID"); + col.pack(); + + col = new TableColumn(tab, SWT.RIGHT); + col.setText("000.000WW"); //$NON-NLS-1$ + col.pack(); + col.setText("Heap Size"); + + col = new TableColumn(tab, SWT.RIGHT); + col.setText("000.000WW"); //$NON-NLS-1$ + col.pack(); + col.setText("Allocated"); + + col = new TableColumn(tab, SWT.RIGHT); + col.setText("000.000WW"); //$NON-NLS-1$ + col.pack(); + col.setText("Free"); + + col = new TableColumn(tab, SWT.RIGHT); + col.setText("000.00%"); //$NON-NLS-1$ + col.pack(); + col.setText("% Used"); + + col = new TableColumn(tab, SWT.RIGHT); + col.setText("000,000,000"); //$NON-NLS-1$ + col.pack(); + col.setText("# Objects"); + + // make sure there is always one empty item so that one table row is always displayed. + TableItem item = new TableItem(tab, SWT.NONE); + item.setText(""); + + return tab; + } + + private Table createDetailedTable(Composite base) { + IPreferenceStore store = DdmUiPreferences.getStore(); + + Table tab = new Table(base, SWT.SINGLE | SWT.FULL_SELECTION); + tab.setHeaderVisible(true); + tab.setLinesVisible(true); + + TableHelper.createTableColumn(tab, "Type", SWT.LEFT, + "4-byte array (object[], int[], float[])", //$NON-NLS-1$ + PREFS_STATS_COL_TYPE, store); + + TableHelper.createTableColumn(tab, "Count", SWT.RIGHT, + "00,000", //$NON-NLS-1$ + PREFS_STATS_COL_COUNT, store); + + TableHelper.createTableColumn(tab, "Total Size", SWT.RIGHT, + "000.000 WW", //$NON-NLS-1$ + PREFS_STATS_COL_SIZE, store); + + TableHelper.createTableColumn(tab, "Smallest", SWT.RIGHT, + "000.000 WW", //$NON-NLS-1$ + PREFS_STATS_COL_SMALLEST, store); + + TableHelper.createTableColumn(tab, "Largest", SWT.RIGHT, + "000.000 WW", //$NON-NLS-1$ + PREFS_STATS_COL_LARGEST, store); + + TableHelper.createTableColumn(tab, "Median", SWT.RIGHT, + "000.000 WW", //$NON-NLS-1$ + PREFS_STATS_COL_MEDIAN, store); + + TableHelper.createTableColumn(tab, "Average", SWT.RIGHT, + "000.000 WW", //$NON-NLS-1$ + PREFS_STATS_COL_AVERAGE, store); + + tab.addSelectionListener(new SelectionAdapter() { + @Override + public void widgetSelected(SelectionEvent e) { + + Client client = getCurrentClient(); + if (client != null) { + int index = mStatisticsTable.getSelectionIndex(); + TableItem item = mStatisticsTable.getItem(index); + + if (item != null) { + Map> heapMap = + client.getClientData().getVmHeapData().getProcessedHeapMap(); + + ArrayList list = heapMap.get(item.getData()); + if (list != null) { + showChart(list); + } + } + } + + } + }); + + return tab; + } + + /** + * Creates the chart below the statistics table + */ + private void createChart() { + mAllocCountDataSet = new DefaultCategoryDataset(); + mChart = ChartFactory.createBarChart(null, "Size", "Count", mAllocCountDataSet, + PlotOrientation.VERTICAL, false, true, false); + + // get the font to make a proper title. We need to convert the swt font, + // into an awt font. + Font f = mStatisticsBase.getFont(); + FontData[] fData = f.getFontData(); + + // event though on Mac OS there could be more than one fontData, we'll only use + // the first one. + FontData firstFontData = fData[0]; + + java.awt.Font awtFont = SWTUtils.toAwtFont(mStatisticsBase.getDisplay(), + firstFontData, true /* ensureSameSize */); + + mChart.setTitle(new TextTitle("Allocation count per size", awtFont)); + + Plot plot = mChart.getPlot(); + if (plot instanceof CategoryPlot) { + // get the plot + CategoryPlot categoryPlot = (CategoryPlot)plot; + + // set the domain axis to draw labels that are displayed even with many values. + CategoryAxis domainAxis = categoryPlot.getDomainAxis(); + domainAxis.setCategoryLabelPositions(CategoryLabelPositions.DOWN_90); + + CategoryItemRenderer renderer = categoryPlot.getRenderer(); + renderer.setBaseToolTipGenerator(new CategoryToolTipGenerator() { + @Override + public String generateToolTip(CategoryDataset dataset, int row, int column) { + // get the key for the size of the allocation + ByteLong columnKey = (ByteLong)dataset.getColumnKey(column); + String rowKey = (String)dataset.getRowKey(row); + Number value = dataset.getValue(rowKey, columnKey); + + return String.format("%1$d %2$s of %3$d bytes", value.intValue(), rowKey, + columnKey.getValue()); + } + }); + } + mChartComposite = new ChartComposite(mStatisticsBase, SWT.BORDER, mChart, + ChartComposite.DEFAULT_WIDTH, + ChartComposite.DEFAULT_HEIGHT, + ChartComposite.DEFAULT_MINIMUM_DRAW_WIDTH, + ChartComposite.DEFAULT_MINIMUM_DRAW_HEIGHT, + 3000, // max draw width. We don't want it to zoom, so we put a big number + 3000, // max draw height. We don't want it to zoom, so we put a big number + true, // off-screen buffer + true, // properties + true, // save + true, // print + false, // zoom + true); // tooltips + + mChartComposite.setLayoutData(new GridData(GridData.FILL_BOTH)); + } + + private static String prettyByteCount(long bytes) { + double fracBytes = bytes; + String units = " B"; + if (fracBytes < 1024) { + return sByteFormatter.format(fracBytes) + units; + } else { + fracBytes /= 1024; + units = " KB"; + } + if (fracBytes >= 1024) { + fracBytes /= 1024; + units = " MB"; + } + if (fracBytes >= 1024) { + fracBytes /= 1024; + units = " GB"; + } + + return sLargeByteFormatter.format(fracBytes) + units; + } + + private static String approximateByteCount(long bytes) { + double fracBytes = bytes; + String units = ""; + if (fracBytes >= 1024) { + fracBytes /= 1024; + units = "K"; + } + if (fracBytes >= 1024) { + fracBytes /= 1024; + units = "M"; + } + if (fracBytes >= 1024) { + fracBytes /= 1024; + units = "G"; + } + + return sByteFormatter.format(fracBytes) + units; + } + + private static String addCommasToNumber(long num) { + return sCountFormatter.format(num); + } + + private static String fractionalPercent(long num, long denom) { + double val = (double)num / (double)denom; + val *= 100; + + NumberFormat nf = NumberFormat.getInstance(); + nf.setMinimumFractionDigits(2); + nf.setMaximumFractionDigits(2); + return nf.format(val) + "%"; + } + + private void fillSummaryTable(ClientData cd) { + if (mHeapSummary.isDisposed()) { + return; + } + + mHeapSummary.setRedraw(false); + mHeapSummary.removeAll(); + + int numRows = 0; + if (cd != null) { + synchronized (cd) { + Iterator iter = cd.getVmHeapIds(); + + while (iter.hasNext()) { + numRows++; + Integer id = iter.next(); + Map heapInfo = cd.getVmHeapInfo(id); + if (heapInfo == null) { + continue; + } + long sizeInBytes = heapInfo.get(ClientData.HEAP_SIZE_BYTES); + long bytesAllocated = heapInfo.get(ClientData.HEAP_BYTES_ALLOCATED); + long objectsAllocated = heapInfo.get(ClientData.HEAP_OBJECTS_ALLOCATED); + + TableItem item = new TableItem(mHeapSummary, SWT.NONE); + item.setText(0, id.toString()); + + item.setText(1, prettyByteCount(sizeInBytes)); + item.setText(2, prettyByteCount(bytesAllocated)); + item.setText(3, prettyByteCount(sizeInBytes - bytesAllocated)); + item.setText(4, fractionalPercent(bytesAllocated, sizeInBytes)); + item.setText(5, addCommasToNumber(objectsAllocated)); + } + } + } + + if (numRows == 0) { + // make sure there is always one empty item so that one table row is always displayed. + TableItem item = new TableItem(mHeapSummary, SWT.NONE); + item.setText(""); + } + + mHeapSummary.pack(); + mHeapSummary.setRedraw(true); + } + + private void fillDetailedTable(Client client, boolean forceRedraw) { + // first check if the client is invalid or heap updates are not enabled. + if (client == null || client.isHeapUpdateEnabled() == false) { + mStatisticsTable.removeAll(); + showChart(null); + return; + } + + ClientData cd = client.getClientData(); + + Map> heapMap; + + // Atomically get and clear the heap data. + synchronized (cd) { + if (serializeHeapData(cd.getVmHeapData()) == false && forceRedraw == false) { + // no change, we return. + return; + } + + heapMap = cd.getVmHeapData().getProcessedHeapMap(); + } + + // we have new data, lets display it. + + // First, get the current selection, and its key. + int index = mStatisticsTable.getSelectionIndex(); + Integer selectedKey = null; + if (index != -1) { + selectedKey = (Integer)mStatisticsTable.getItem(index).getData(); + } + + // disable redraws and remove all from the table. + mStatisticsTable.setRedraw(false); + mStatisticsTable.removeAll(); + + if (heapMap != null) { + int selectedIndex = -1; + ArrayList selectedList = null; + + // get the keys + Set keys = heapMap.keySet(); + int iter = 0; // use a manual iter int because Set doesn't have an index + // based accessor. + for (Integer key : keys) { + ArrayList list = heapMap.get(key); + + // check if this is the key that is supposed to be selected + if (key.equals(selectedKey)) { + selectedIndex = iter; + selectedList = list; + } + iter++; + + TableItem item = new TableItem(mStatisticsTable, SWT.NONE); + item.setData(key); + + // get the type + item.setText(0, mMapLegend[key]); + + // set the count, smallest, largest + int count = list.size(); + item.setText(1, addCommasToNumber(count)); + + if (count > 0) { + item.setText(3, prettyByteCount(list.get(0).getLength())); + item.setText(4, prettyByteCount(list.get(count-1).getLength())); + + int median = count / 2; + HeapSegmentElement element = list.get(median); + long size = element.getLength(); + item.setText(5, prettyByteCount(size)); + + long totalSize = 0; + for (int i = 0 ; i < count; i++) { + element = list.get(i); + + size = element.getLength(); + totalSize += size; + } + + // set the average and total + item.setText(2, prettyByteCount(totalSize)); + item.setText(6, prettyByteCount(totalSize / count)); + } + } + + mStatisticsTable.setRedraw(true); + + if (selectedIndex != -1) { + mStatisticsTable.setSelection(selectedIndex); + showChart(selectedList); + } else { + showChart(null); + } + } else { + mStatisticsTable.setRedraw(true); + } + } + + private static class ByteLong implements Comparable { + private long mValue; + + private ByteLong(long value) { + mValue = value; + } + + public long getValue() { + return mValue; + } + + @Override + public String toString() { + return approximateByteCount(mValue); + } + + @Override + public int compareTo(ByteLong other) { + if (mValue != other.mValue) { + return mValue < other.mValue ? -1 : 1; + } + return 0; + } + + } + + /** + * Fills the chart with the content of the list of {@link HeapSegmentElement}. + */ + private void showChart(ArrayList list) { + mAllocCountDataSet.clear(); + + if (list != null) { + String rowKey = "Alloc Count"; + + long currentSize = -1; + int currentCount = 0; + for (HeapSegmentElement element : list) { + if (element.getLength() != currentSize) { + if (currentSize != -1) { + ByteLong columnKey = new ByteLong(currentSize); + mAllocCountDataSet.addValue(currentCount, rowKey, columnKey); + } + + currentSize = element.getLength(); + currentCount = 1; + } else { + currentCount++; + } + } + + // add the last item + if (currentSize != -1) { + ByteLong columnKey = new ByteLong(currentSize); + mAllocCountDataSet.addValue(currentCount, rowKey, columnKey); + } + } + } + + /* + * Add a color legend to the specified table. + */ + private void createLegend(Composite parent) { + mLegend = new Group(parent, SWT.NONE); + mLegend.setText(getLegendText(0)); + + mLegend.setLayout(new GridLayout(2, false)); + + RGB[] colors = mMapPalette.colors; + + for (int i = 0; i < NUM_PALETTE_ENTRIES; i++) { + Image tmpImage = createColorRect(parent.getDisplay(), colors[i]); + + Label l = new Label(mLegend, SWT.NONE); + l.setImage(tmpImage); + + l = new Label(mLegend, SWT.NONE); + l.setText(mMapLegend[i]); + } + } + + private String getLegendText(int level) { + int bytes = 8 * (100 / ZOOMS[level]); + + return String.format("Key (1 pixel = %1$d bytes)", bytes); + } + + private void setLegendText(int level) { + mLegend.setText(getLegendText(level)); + + } + + /* + * Create a nice rectangle in the specified color. + */ + private Image createColorRect(Display display, RGB color) { + int width = 32; + int height = 16; + + Image img = new Image(display, width, height); + GC gc = new GC(img); + gc.setBackground(new Color(display, color)); + gc.fillRectangle(0, 0, width, height); + gc.dispose(); + return img; + } + + + /* + * Are updates enabled? + */ + private void setUpdateStatus(int status) { + switch (status) { + case NOT_SELECTED: + mUpdateStatus.setText("Select a client to see heap updates"); + break; + case NOT_ENABLED: + mUpdateStatus.setText("Heap updates are " + + "NOT ENABLED for this client"); + break; + case ENABLED: + mUpdateStatus.setText("Heap updates will happen after " + + "every GC for this client"); + break; + default: + throw new RuntimeException(); + } + + mUpdateStatus.pack(); + } + + + /** + * Return the closest power of two greater than or equal to value. + * + * @param value the return value will be >= value + * @return a power of two >= value. If value > 2^31, 2^31 is returned. + */ +//xxx use Integer.highestOneBit() or numberOfLeadingZeros(). + private int nextPow2(int value) { + for (int i = 31; i >= 0; --i) { + if ((value & (1<>> 2) & 1) << 1 | + ((i >>> 4) & 1) << 2 | + ((i >>> 6) & 1) << 3 | + ((i >>> 8) & 1) << 4 | + ((i >>> 10) & 1) << 5 | + ((i >>> 12) & 1) << 6 | + ((i >>> 14) & 1) << 7 | + ((i >>> 16) & 1) << 8 | + ((i >>> 18) & 1) << 9 | + ((i >>> 20) & 1) << 10 | + ((i >>> 22) & 1) << 11 | + ((i >>> 24) & 1) << 12 | + ((i >>> 26) & 1) << 13 | + ((i >>> 28) & 1) << 14 | + ((i >>> 30) & 1) << 15; + int y = ((i >>> 1) & 1) << 0 | + ((i >>> 3) & 1) << 1 | + ((i >>> 5) & 1) << 2 | + ((i >>> 7) & 1) << 3 | + ((i >>> 9) & 1) << 4 | + ((i >>> 11) & 1) << 5 | + ((i >>> 13) & 1) << 6 | + ((i >>> 15) & 1) << 7 | + ((i >>> 17) & 1) << 8 | + ((i >>> 19) & 1) << 9 | + ((i >>> 21) & 1) << 10 | + ((i >>> 23) & 1) << 11 | + ((i >>> 25) & 1) << 12 | + ((i >>> 27) & 1) << 13 | + ((i >>> 29) & 1) << 14 | + ((i >>> 31) & 1) << 15; + try { + id.setPixel(x, y, pixData[i]); + if (x > maxX) { + maxX = x; + } + } catch (IllegalArgumentException ex) { + System.out.println("bad pixels: i " + i + + ", w " + id.width + + ", h " + id.height + + ", x " + x + + ", y " + y); + throw ex; + } + } + return maxX; + } + + private final static int HILBERT_DIR_N = 0; + private final static int HILBERT_DIR_S = 1; + private final static int HILBERT_DIR_E = 2; + private final static int HILBERT_DIR_W = 3; + + private void hilbertWalk(ImageData id, InputStream pixData, + int order, int x, int y, int dir) + throws IOException { + if (x >= id.width || y >= id.height) { + return; + } else if (order == 0) { + try { + int p = pixData.read(); + if (p >= 0) { + // flip along x=y axis; assume width == height + id.setPixel(y, x, p); + + /* Skanky; use an otherwise-unused ImageData field + * to keep track of the max x,y used. Note that x and y are inverted. + */ + if (y > id.x) { + id.x = y; + } + if (x > id.y) { + id.y = x; + } + } +//xxx just give up; don't bother walking the rest of the image + } catch (IllegalArgumentException ex) { + System.out.println("bad pixels: order " + order + + ", dir " + dir + + ", w " + id.width + + ", h " + id.height + + ", x " + x + + ", y " + y); + throw ex; + } + } else { + order--; + int delta = 1 << order; + int nextX = x + delta; + int nextY = y + delta; + + switch (dir) { + case HILBERT_DIR_E: + hilbertWalk(id, pixData, order, x, y, HILBERT_DIR_N); + hilbertWalk(id, pixData, order, x, nextY, HILBERT_DIR_E); + hilbertWalk(id, pixData, order, nextX, nextY, HILBERT_DIR_E); + hilbertWalk(id, pixData, order, nextX, y, HILBERT_DIR_S); + break; + case HILBERT_DIR_N: + hilbertWalk(id, pixData, order, x, y, HILBERT_DIR_E); + hilbertWalk(id, pixData, order, nextX, y, HILBERT_DIR_N); + hilbertWalk(id, pixData, order, nextX, nextY, HILBERT_DIR_N); + hilbertWalk(id, pixData, order, x, nextY, HILBERT_DIR_W); + break; + case HILBERT_DIR_S: + hilbertWalk(id, pixData, order, nextX, nextY, HILBERT_DIR_W); + hilbertWalk(id, pixData, order, x, nextY, HILBERT_DIR_S); + hilbertWalk(id, pixData, order, x, y, HILBERT_DIR_S); + hilbertWalk(id, pixData, order, nextX, y, HILBERT_DIR_E); + break; + case HILBERT_DIR_W: + hilbertWalk(id, pixData, order, nextX, nextY, HILBERT_DIR_S); + hilbertWalk(id, pixData, order, nextX, y, HILBERT_DIR_W); + hilbertWalk(id, pixData, order, x, y, HILBERT_DIR_W); + hilbertWalk(id, pixData, order, x, nextY, HILBERT_DIR_N); + break; + default: + throw new RuntimeException("Unexpected Hilbert direction " + + dir); + } + } + } + + private Point hilbertOrderData(ImageData id, byte pixData[]) { + + int order = 0; + for (int n = 1; n < id.width; n *= 2) { + order++; + } + /* Skanky; use an otherwise-unused ImageData field + * to keep track of maxX. + */ + Point p = new Point(0,0); + int oldIdX = id.x; + int oldIdY = id.y; + id.x = id.y = 0; + try { + hilbertWalk(id, new ByteArrayInputStream(pixData), + order, 0, 0, HILBERT_DIR_E); + p.x = id.x; + p.y = id.y; + } catch (IOException ex) { + System.err.println("Exception during hilbertWalk()"); + p.x = id.height; + p.y = id.width; + } + id.x = oldIdX; + id.y = oldIdY; + return p; + } + + private ImageData createHilbertHeapImage(byte pixData[]) { + int w, h; + + // Pick an image size that the largest of heaps will fit into. + w = (int)Math.sqrt(((16 * 1024 * 1024)/8)); + + // Space-filling curves require a power-of-2 width. + w = nextPow2(w); + h = w; + + // Create the heap image. + ImageData id = new ImageData(w, h, 8, mMapPalette); + + // Copy the data into the image + //int maxX = zOrderData(id, pixData); + Point maxP = hilbertOrderData(id, pixData); + + // update the max size to make it a round number once the zoom is applied + int factor = 100 / ZOOMS[mZoom.getSelectionIndex()]; + if (factor != 1) { + int tmp = maxP.x % factor; + if (tmp != 0) { + maxP.x += factor - tmp; + } + + tmp = maxP.y % factor; + if (tmp != 0) { + maxP.y += factor - tmp; + } + } + + if (maxP.y < id.height) { + // Crop the image down to the interesting part. + id = new ImageData(id.width, maxP.y, id.depth, id.palette, + id.scanlinePad, id.data); + } + + if (maxP.x < id.width) { + // crop the image again. A bit trickier this time. + ImageData croppedId = new ImageData(maxP.x, id.height, id.depth, id.palette); + + int[] buffer = new int[maxP.x]; + for (int l = 0 ; l < id.height; l++) { + id.getPixels(0, l, maxP.x, buffer, 0); + croppedId.setPixels(0, l, maxP.x, buffer, 0); + } + + id = croppedId; + } + + // apply the zoom + if (factor != 1) { + id = id.scaledTo(id.width / factor, id.height / factor); + } + + return id; + } + + /** + * Convert the raw heap data to an image. We know we're running in + * the UI thread, so we can issue graphics commands directly. + * + * http://help.eclipse.org/help31/nftopic/org.eclipse.platform.doc.isv/reference/api/org/eclipse/swt/graphics/GC.html + * + * @param cd The client data + * @param mode The display mode. 0 = linear, 1 = hilbert. + * @param forceRedraw + */ + private void renderHeapData(ClientData cd, int mode, boolean forceRedraw) { + Image image; + + byte[] pixData; + + // Atomically get and clear the heap data. + synchronized (cd) { + if (serializeHeapData(cd.getVmHeapData()) == false && forceRedraw == false) { + // no change, we return. + return; + } + + pixData = getSerializedData(); + } + + if (pixData != null) { + ImageData id; + if (mode == 1) { + id = createHilbertHeapImage(pixData); + } else { + id = createLinearHeapImage(pixData, 200, mMapPalette); + } + + image = new Image(mDisplay, id); + } else { + // Render a placeholder image. + int width, height; + if (mode == 1) { + width = height = PLACEHOLDER_HILBERT_SIZE; + } else { + width = PLACEHOLDER_LINEAR_H_SIZE; + height = PLACEHOLDER_LINEAR_V_SIZE; + } + image = new Image(mDisplay, width, height); + GC gc = new GC(image); + gc.setForeground(mDisplay.getSystemColor(SWT.COLOR_RED)); + gc.drawLine(0, 0, width-1, height-1); + gc.dispose(); + gc = null; + } + + // set the new image + + if (mode == 1) { + if (mHilbertImage != null) { + mHilbertImage.dispose(); + } + + mHilbertImage = image; + mHilbertHeapImage.setImage(mHilbertImage); + mHilbertHeapImage.pack(true); + mHilbertBase.layout(); + mHilbertBase.pack(true); + } else { + if (mLinearImage != null) { + mLinearImage.dispose(); + } + + mLinearImage = image; + mLinearHeapImage.setImage(mLinearImage); + mLinearHeapImage.pack(true); + mLinearBase.layout(); + mLinearBase.pack(true); + } + } + + @Override + protected void setTableFocusListener() { + addTableToFocusListener(mHeapSummary); + } +} + diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/ITableFocusListener.java b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/ITableFocusListener.java new file mode 100644 index 00000000..37dd9a03 --- /dev/null +++ b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/ITableFocusListener.java @@ -0,0 +1,38 @@ +/* + * Copyright (C) 2007 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.ddmuilib; + +import org.eclipse.swt.dnd.Clipboard; + +/** + * An object listening to focus change in Table objects.
+ * For application not relying on a RCP to provide menu changes based on focus, + * this class allows to get monitor the focus change of several Table widget + * and update the menu action accordingly. + */ +public interface ITableFocusListener { + + public interface IFocusedTableActivator { + public void copy(Clipboard clipboard); + + public void selectAll(); + } + + public void focusGained(IFocusedTableActivator activator); + + public void focusLost(IFocusedTableActivator activator); +} diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/ImageLoader.java b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/ImageLoader.java new file mode 100644 index 00000000..fd480f64 --- /dev/null +++ b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/ImageLoader.java @@ -0,0 +1,206 @@ +/* + * Copyright (C) 2007 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.ddmuilib; + +import com.android.ddmlib.Log; + +import org.eclipse.jface.resource.ImageDescriptor; +import org.eclipse.swt.SWT; +import org.eclipse.swt.graphics.Color; +import org.eclipse.swt.graphics.GC; +import org.eclipse.swt.graphics.Image; +import org.eclipse.swt.widgets.Display; + +import java.io.InputStream; +import java.net.URL; +import java.util.HashMap; + +/** + * Class to load images stored in a jar file. + * All images are loaded from /images/filename + * + * Because Java requires to know the jar file in which to load the image from, a class is required + * when getting the instance. Instances are cached and associated to the class passed to + * {@link #getLoader(Class)}. + * + * {@link #getDdmUiLibLoader()} use {@link ImageLoader#getClass()} as the class. This is to be used + * to load images from ddmuilib. + * + * Loaded images are stored so that 2 calls with the same filename will return the same object. + * This also means that {@link Image} object returned by the loader should never be disposed. + * + */ +public class ImageLoader { + + private static final String PATH = "/images/"; //$NON-NLS-1$ + + private final HashMap mLoadedImages = new HashMap(); + private static final HashMap, ImageLoader> mInstances = + new HashMap, ImageLoader>(); + private final Class mClass; + + /** + * Private constructor, creating an instance associated with a class. + * The class is used to identify which jar file the images are loaded from. + */ + private ImageLoader(Class theClass) { + if (theClass == null) { + theClass = ImageLoader.class; + } + mClass = theClass; + } + + /** + * Returns the {@link ImageLoader} instance to load images from ddmuilib.jar + */ + public static ImageLoader getDdmUiLibLoader() { + return getLoader(null); + } + + /** + * Returns an {@link ImageLoader} to load images based on a given class. + * + * The loader will load images from the jar from which the class was loaded. using + * {@link Class#getResource(String)} and {@link Class#getResourceAsStream(String)}. + * + * Since all images are loaded using the path /images/filename, any class from the + * jar will work. However since the loader is cached and reused when the query provides the same + * class instance, and since the loader will also cache the loaded images, it is recommended + * to always use the same class for a given Jar file. + * + */ + public static ImageLoader getLoader(Class theClass) { + ImageLoader instance = mInstances.get(theClass); + if (instance == null) { + instance = new ImageLoader(theClass); + mInstances.put(theClass, instance); + } + + return instance; + } + + /** + * Disposes all images for all instances. + * This should only be called when the program exits. + */ + public static void dispose() { + for (ImageLoader loader : mInstances.values()) { + loader.doDispose(); + } + } + + private synchronized void doDispose() { + for (Image image : mLoadedImages.values()) { + image.dispose(); + } + + mLoadedImages.clear(); + } + + /** + * Returns an {@link ImageDescriptor} for a given filename. + * + * This searches for an image located at /images/filename. + * + * @param filename the filename of the image to load. + */ + public ImageDescriptor loadDescriptor(String filename) { + URL url = mClass.getResource(PATH + filename); + // TODO cache in a map + return ImageDescriptor.createFromURL(url); + } + + /** + * Returns an {@link Image} for a given filename. + * + * This searches for an image located at /images/filename. + * + * @param filename the filename of the image to load. + * @param display the Display object + */ + public synchronized Image loadImage(String filename, Display display) { + Image img = mLoadedImages.get(filename); + if (img == null) { + String tmp = PATH + filename; + InputStream imageStream = mClass.getResourceAsStream(tmp); + + if (imageStream != null) { + img = new Image(display, imageStream); + mLoadedImages.put(filename, img); + } + + if (img == null) { + throw new RuntimeException("Failed to load " + tmp); + } + } + + return img; + } + + /** + * Loads an image from a resource. This method used a class to locate the + * resources, and then load the filename from /images inside the resources.
+ * Extra parameters allows for creation of a replacement image of the + * loading failed. + * + * @param display the Display object + * @param fileName the file name + * @param width optional width to create replacement Image. If -1, null be + * be returned if the loading fails. + * @param height optional height to create replacement Image. If -1, null be + * be returned if the loading fails. + * @param phColor optional color to create replacement Image. If null, Blue + * color will be used. + * @return a new Image or null if the loading failed and the optional + * replacement size was -1 + */ + public Image loadImage(Display display, String fileName, int width, int height, + Color phColor) { + + Image img = loadImage(fileName, display); + + if (img == null) { + Log.w("ddms", "Couldn't load " + fileName); + // if we had the extra parameter to create replacement image then we + // create and return it. + if (width != -1 && height != -1) { + return createPlaceHolderArt(display, width, height, + phColor != null ? phColor : display + .getSystemColor(SWT.COLOR_BLUE)); + } + + // otherwise, just return null + return null; + } + + return img; + } + + /** + * Create place-holder art with the specified color. + */ + public static Image createPlaceHolderArt(Display display, int width, + int height, Color color) { + Image img = new Image(display, width, height); + GC gc = new GC(img); + gc.setForeground(color); + gc.drawLine(0, 0, width, height); + gc.drawLine(0, height - 1, width, -1); + gc.dispose(); + return img; + } +} diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/InfoPanel.java b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/InfoPanel.java new file mode 100644 index 00000000..60dc2c0e --- /dev/null +++ b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/InfoPanel.java @@ -0,0 +1,199 @@ +/* + * Copyright (C) 2007 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.ddmuilib; + +import com.android.ddmlib.AndroidDebugBridge.IClientChangeListener; +import com.android.ddmlib.Client; +import com.android.ddmlib.ClientData; + +import org.eclipse.swt.SWT; +import org.eclipse.swt.widgets.Composite; +import org.eclipse.swt.widgets.Control; +import org.eclipse.swt.widgets.Table; +import org.eclipse.swt.widgets.TableColumn; +import org.eclipse.swt.widgets.TableItem; + +/** + * Display client info in a two-column format. + */ +public class InfoPanel extends TablePanel { + private Table mTable; + private TableColumn mCol2; + + private static final String mLabels[] = { + "DDM-aware?", + "App description:", + "VM version:", + "Process ID:", + "Supports Profiling Control:", + "Supports HPROF Control:", + }; + private static final int ENT_DDM_AWARE = 0; + private static final int ENT_APP_DESCR = 1; + private static final int ENT_VM_VERSION = 2; + private static final int ENT_PROCESS_ID = 3; + private static final int ENT_SUPPORTS_PROFILING = 4; + private static final int ENT_SUPPORTS_HPROF = 5; + + /** + * Create our control(s). + */ + @Override + protected Control createControl(Composite parent) { + mTable = new Table(parent, SWT.MULTI | SWT.FULL_SELECTION); + mTable.setHeaderVisible(false); + mTable.setLinesVisible(false); + + TableColumn col1 = new TableColumn(mTable, SWT.RIGHT); + col1.setText("name"); + mCol2 = new TableColumn(mTable, SWT.LEFT); + mCol2.setText("PlaceHolderContentForWidth"); + + TableItem item; + for (int i = 0; i < mLabels.length; i++) { + item = new TableItem(mTable, SWT.NONE); + item.setText(0, mLabels[i]); + item.setText(1, "-"); + } + + col1.pack(); + mCol2.pack(); + + return mTable; + } + + /** + * Sets the focus to the proper control inside the panel. + */ + @Override + public void setFocus() { + mTable.setFocus(); + } + + + /** + * Sent when an existing client information changed. + *

+ * This is sent from a non UI thread. + * @param client the updated client. + * @param changeMask the bit mask describing the changed properties. It can contain + * any of the following values: {@link Client#CHANGE_PORT}, {@link Client#CHANGE_NAME} + * {@link Client#CHANGE_DEBUGGER_STATUS}, {@link Client#CHANGE_THREAD_MODE}, + * {@link Client#CHANGE_THREAD_DATA}, {@link Client#CHANGE_HEAP_MODE}, + * {@link Client#CHANGE_HEAP_DATA}, {@link Client#CHANGE_NATIVE_HEAP_DATA} + * + * @see IClientChangeListener#clientChanged(Client, int) + */ + @Override + public void clientChanged(final Client client, int changeMask) { + if (client == getCurrentClient()) { + if ((changeMask & Client.CHANGE_INFO) == Client.CHANGE_INFO) { + if (mTable.isDisposed()) + return; + + mTable.getDisplay().asyncExec(new Runnable() { + @Override + public void run() { + clientSelected(); + } + }); + } + } + } + + + /** + * Sent when a new device is selected. The new device can be accessed + * with {@link #getCurrentDevice()} + */ + @Override + public void deviceSelected() { + // pass + } + + /** + * Sent when a new client is selected. The new client can be accessed + * with {@link #getCurrentClient()} + */ + @Override + public void clientSelected() { + if (mTable.isDisposed()) + return; + + Client client = getCurrentClient(); + + if (client == null) { + for (int i = 0; i < mLabels.length; i++) { + TableItem item = mTable.getItem(i); + item.setText(1, "-"); + } + } else { + TableItem item; + String clientDescription, vmIdentifier, isDdmAware, + pid; + + ClientData cd = client.getClientData(); + synchronized (cd) { + clientDescription = (cd.getClientDescription() != null) ? + cd.getClientDescription() : "?"; + vmIdentifier = (cd.getVmIdentifier() != null) ? + cd.getVmIdentifier() : "?"; + isDdmAware = cd.isDdmAware() ? + "yes" : "no"; + pid = (cd.getPid() != 0) ? + String.valueOf(cd.getPid()) : "?"; + } + + item = mTable.getItem(ENT_APP_DESCR); + item.setText(1, clientDescription); + item = mTable.getItem(ENT_VM_VERSION); + item.setText(1, vmIdentifier); + item = mTable.getItem(ENT_DDM_AWARE); + item.setText(1, isDdmAware); + item = mTable.getItem(ENT_PROCESS_ID); + item.setText(1, pid); + + item = mTable.getItem(ENT_SUPPORTS_PROFILING); + if (cd.hasFeature(ClientData.FEATURE_PROFILING_STREAMING)) { + item.setText(1, "Yes"); + } else if (cd.hasFeature(ClientData.FEATURE_PROFILING)) { + item.setText(1, "Yes (Application must be able to write on the SD Card)"); + } else { + item.setText(1, "No"); + } + + item = mTable.getItem(ENT_SUPPORTS_HPROF); + if (cd.hasFeature(ClientData.FEATURE_HPROF_STREAMING)) { + item.setText(1, "Yes"); + } else if (cd.hasFeature(ClientData.FEATURE_HPROF)) { + item.setText(1, "Yes (Application must be able to write on the SD Card)"); + } else { + item.setText(1, "No"); + } + } + + mCol2.pack(); + + //Log.i("ddms", "InfoPanel: changed " + client); + } + + @Override + protected void setTableFocusListener() { + addTableToFocusListener(mTable); + } +} + diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/NativeHeapPanel.java b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/NativeHeapPanel.java new file mode 100644 index 00000000..337bff29 --- /dev/null +++ b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/NativeHeapPanel.java @@ -0,0 +1,1648 @@ +/* + * Copyright (C) 2007 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.ddmuilib; + +import com.android.ddmlib.AndroidDebugBridge.IClientChangeListener; +import com.android.ddmlib.Client; +import com.android.ddmlib.ClientData; +import com.android.ddmlib.HeapSegment.HeapSegmentElement; +import com.android.ddmlib.Log; +import com.android.ddmlib.NativeAllocationInfo; +import com.android.ddmlib.NativeLibraryMapInfo; +import com.android.ddmlib.NativeStackCallInfo; +import com.android.ddmuilib.annotation.WorkerThread; + +import org.eclipse.jface.preference.IPreferenceStore; +import org.eclipse.swt.SWT; +import org.eclipse.swt.SWTException; +import org.eclipse.swt.custom.StackLayout; +import org.eclipse.swt.events.SelectionAdapter; +import org.eclipse.swt.events.SelectionEvent; +import org.eclipse.swt.graphics.Image; +import org.eclipse.swt.graphics.ImageData; +import org.eclipse.swt.graphics.PaletteData; +import org.eclipse.swt.graphics.RGB; +import org.eclipse.swt.graphics.Rectangle; +import org.eclipse.swt.layout.FormAttachment; +import org.eclipse.swt.layout.FormData; +import org.eclipse.swt.layout.FormLayout; +import org.eclipse.swt.layout.GridData; +import org.eclipse.swt.layout.GridLayout; +import org.eclipse.swt.widgets.Button; +import org.eclipse.swt.widgets.Combo; +import org.eclipse.swt.widgets.Composite; +import org.eclipse.swt.widgets.Control; +import org.eclipse.swt.widgets.Display; +import org.eclipse.swt.widgets.Event; +import org.eclipse.swt.widgets.FileDialog; +import org.eclipse.swt.widgets.Label; +import org.eclipse.swt.widgets.Listener; +import org.eclipse.swt.widgets.Sash; +import org.eclipse.swt.widgets.Table; +import org.eclipse.swt.widgets.TableItem; + +import java.io.BufferedWriter; +import java.io.FileWriter; +import java.io.IOException; +import java.io.PrintWriter; +import java.text.DecimalFormat; +import java.text.NumberFormat; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; + +/** + * Panel with native heap information. + */ +public final class NativeHeapPanel extends BaseHeapPanel { + + /** color palette and map legend. NATIVE is the last enum is a 0 based enum list, so we need + * Native+1 at least. We also need 2 more entries for free area and expansion area. */ + private static final int NUM_PALETTE_ENTRIES = HeapSegmentElement.KIND_NATIVE+2 +1; + private static final String[] mMapLegend = new String[NUM_PALETTE_ENTRIES]; + private static final PaletteData mMapPalette = createPalette(); + + private static final int ALLOC_DISPLAY_ALL = 0; + private static final int ALLOC_DISPLAY_PRE_ZYGOTE = 1; + private static final int ALLOC_DISPLAY_POST_ZYGOTE = 2; + + private Display mDisplay; + + private Composite mBase; + + private Label mUpdateStatus; + + /** combo giving choice of what to display: all, pre-zygote, post-zygote */ + private Combo mAllocDisplayCombo; + + private Button mFullUpdateButton; + + // see CreateControl() + //private Button mDiffUpdateButton; + + private Combo mDisplayModeCombo; + + /** stack composite for mode (1-2) & 3 */ + private Composite mTopStackComposite; + + private StackLayout mTopStackLayout; + + /** stack composite for mode 1 & 2 */ + private Composite mAllocationStackComposite; + + private StackLayout mAllocationStackLayout; + + /** top level container for mode 1 & 2 */ + private Composite mTableModeControl; + + /** top level object for the allocation mode */ + private Control mAllocationModeTop; + + /** top level for the library mode */ + private Control mLibraryModeTopControl; + + /** composite for page UI and total memory display */ + private Composite mPageUIComposite; + + private Label mTotalMemoryLabel; + + private Label mPageLabel; + + private Button mPageNextButton; + + private Button mPagePreviousButton; + + private Table mAllocationTable; + + private Table mLibraryTable; + + private Table mLibraryAllocationTable; + + private Table mDetailTable; + + private Label mImage; + + private int mAllocDisplayMode = ALLOC_DISPLAY_ALL; + + /** + * pointer to current stackcall thread computation in order to quit it if + * required (new update requested) + */ + private StackCallThread mStackCallThread; + + /** Current Library Allocation table fill thread. killed if selection changes */ + private FillTableThread mFillTableThread; + + /** + * current client data. Used to access the malloc info when switching pages + * or selecting allocation to show stack call + */ + private ClientData mClientData; + + /** + * client data from a previous display. used when asking for an "update & diff" + */ + private ClientData mBackUpClientData; + + /** list of NativeAllocationInfo objects filled with the list from ClientData */ + private final ArrayList mAllocations = + new ArrayList(); + + /** list of the {@link NativeAllocationInfo} being displayed based on the selection + * of {@link #mAllocDisplayCombo}. + */ + private final ArrayList mDisplayedAllocations = + new ArrayList(); + + /** list of NativeAllocationInfo object kept as backup when doing an "update & diff" */ + private final ArrayList mBackUpAllocations = + new ArrayList(); + + /** back up of the total memory, used when doing an "update & diff" */ + private int mBackUpTotalMemory; + + private int mCurrentPage = 0; + + private int mPageCount = 0; + + /** + * list of allocation per Library. This is created from the list of + * NativeAllocationInfo objects that is stored in the ClientData object. Since we + * don't keep this list around, it is recomputed everytime the client + * changes. + */ + private final ArrayList mLibraryAllocations = + new ArrayList(); + + /* args to setUpdateStatus() */ + private static final int NOT_SELECTED = 0; + + private static final int NOT_ENABLED = 1; + + private static final int ENABLED = 2; + + private static final int DISPLAY_PER_PAGE = 20; + + private static final String PREFS_ALLOCATION_SASH = "NHallocSash"; //$NON-NLS-1$ + private static final String PREFS_LIBRARY_SASH = "NHlibrarySash"; //$NON-NLS-1$ + private static final String PREFS_DETAIL_ADDRESS = "NHdetailAddress"; //$NON-NLS-1$ + private static final String PREFS_DETAIL_LIBRARY = "NHdetailLibrary"; //$NON-NLS-1$ + private static final String PREFS_DETAIL_METHOD = "NHdetailMethod"; //$NON-NLS-1$ + private static final String PREFS_DETAIL_FILE = "NHdetailFile"; //$NON-NLS-1$ + private static final String PREFS_DETAIL_LINE = "NHdetailLine"; //$NON-NLS-1$ + private static final String PREFS_ALLOC_TOTAL = "NHallocTotal"; //$NON-NLS-1$ + private static final String PREFS_ALLOC_COUNT = "NHallocCount"; //$NON-NLS-1$ + private static final String PREFS_ALLOC_SIZE = "NHallocSize"; //$NON-NLS-1$ + private static final String PREFS_ALLOC_LIBRARY = "NHallocLib"; //$NON-NLS-1$ + private static final String PREFS_ALLOC_METHOD = "NHallocMethod"; //$NON-NLS-1$ + private static final String PREFS_ALLOC_FILE = "NHallocFile"; //$NON-NLS-1$ + private static final String PREFS_LIB_LIBRARY = "NHlibLibrary"; //$NON-NLS-1$ + private static final String PREFS_LIB_SIZE = "NHlibSize"; //$NON-NLS-1$ + private static final String PREFS_LIB_COUNT = "NHlibCount"; //$NON-NLS-1$ + private static final String PREFS_LIBALLOC_TOTAL = "NHlibAllocTotal"; //$NON-NLS-1$ + private static final String PREFS_LIBALLOC_COUNT = "NHlibAllocCount"; //$NON-NLS-1$ + private static final String PREFS_LIBALLOC_SIZE = "NHlibAllocSize"; //$NON-NLS-1$ + private static final String PREFS_LIBALLOC_METHOD = "NHlibAllocMethod"; //$NON-NLS-1$ + + /** static formatter object to format all numbers as #,### */ + private static DecimalFormat sFormatter; + static { + sFormatter = (DecimalFormat)NumberFormat.getInstance(); + if (sFormatter == null) { + sFormatter = new DecimalFormat("#,###"); + } else { + sFormatter.applyPattern("#,###"); + } + } + + + /** + * caching mechanism to avoid recomputing the backtrace for a particular + * address several times. + */ + private HashMap mSourceCache = + new HashMap(); + private long mTotalSize; + private Button mSaveButton; + private Button mSymbolsButton; + + /** + * thread class to convert the address call into method, file and line + * number in the background. + */ + private class StackCallThread extends BackgroundThread { + private ClientData mClientData; + + public StackCallThread(ClientData cd) { + mClientData = cd; + } + + public ClientData getClientData() { + return mClientData; + } + + @Override + public void run() { + // loop through all the NativeAllocationInfo and init them + Iterator iter = mAllocations.iterator(); + int total = mAllocations.size(); + int count = 0; + while (iter.hasNext()) { + + if (isQuitting()) + return; + + NativeAllocationInfo info = iter.next(); + if (info.isStackCallResolved() == false) { + final List list = info.getStackCallAddresses(); + final int size = list.size(); + + ArrayList resolvedStackCall = + new ArrayList(); + + for (int i = 0; i < size; i++) { + long addr = list.get(i); + + // first check if the addr has already been converted. + NativeStackCallInfo source = mSourceCache.get(addr); + + // if not we convert it + if (source == null) { + source = sourceForAddr(addr); + mSourceCache.put(addr, source); + } + + resolvedStackCall.add(source); + } + + info.setResolvedStackCall(resolvedStackCall); + } + // after every DISPLAY_PER_PAGE we ask for a ui refresh, unless + // we reach total, since we also do it after the loop + // (only an issue in case we have a perfect number of page) + count++; + if ((count % DISPLAY_PER_PAGE) == 0 && count != total) { + if (updateNHAllocationStackCalls(mClientData, count) == false) { + // looks like the app is quitting, so we just + // stopped the thread + return; + } + } + } + + updateNHAllocationStackCalls(mClientData, count); + } + + private NativeStackCallInfo sourceForAddr(long addr) { + NativeLibraryMapInfo library = getLibraryFor(addr); + + if (library != null) { + + Addr2Line process = Addr2Line.getProcess(library); + if (process != null) { + // remove the base of the library address + NativeStackCallInfo info = process.getAddress(addr); + if (info != null) { + return info; + } + } + } + + return new NativeStackCallInfo(addr, + library != null ? library.getLibraryName() : null, + Long.toHexString(addr), + ""); + } + + private NativeLibraryMapInfo getLibraryFor(long addr) { + for (NativeLibraryMapInfo info : mClientData.getMappedNativeLibraries()) { + if (info.isWithinLibrary(addr)) { + return info; + } + } + + Log.d("ddm-nativeheap", "Failed finding Library for " + Long.toHexString(addr)); + return null; + } + + /** + * update the Native Heap panel with the amount of allocation for which the + * stack call has been computed. This is called from a non UI thread, but + * will be executed in the UI thread. + * + * @param count the amount of allocation + * @return false if the display was disposed and the update couldn't happen + */ + private boolean updateNHAllocationStackCalls(final ClientData clientData, final int count) { + if (mDisplay.isDisposed() == false) { + mDisplay.asyncExec(new Runnable() { + @Override + public void run() { + updateAllocationStackCalls(clientData, count); + } + }); + return true; + } + return false; + } + } + + private class FillTableThread extends BackgroundThread { + private LibraryAllocations mLibAlloc; + + private int mMax; + + public FillTableThread(LibraryAllocations liballoc, int m) { + mLibAlloc = liballoc; + mMax = m; + } + + @Override + public void run() { + for (int i = mMax; i > 0 && isQuitting() == false; i -= 10) { + updateNHLibraryAllocationTable(mLibAlloc, mMax - i, mMax - i + 10); + } + } + + /** + * updates the library allocation table in the Native Heap panel. This is + * called from a non UI thread, but will be executed in the UI thread. + * + * @param liballoc the current library allocation object being displayed + * @param start start index of items that need to be displayed + * @param end end index of the items that need to be displayed + */ + private void updateNHLibraryAllocationTable(final LibraryAllocations libAlloc, + final int start, final int end) { + if (mDisplay.isDisposed() == false) { + mDisplay.asyncExec(new Runnable() { + @Override + public void run() { + updateLibraryAllocationTable(libAlloc, start, end); + } + }); + } + + } + } + + /** class to aggregate allocations per library */ + public static class LibraryAllocations { + private String mLibrary; + + private final ArrayList mLibAllocations = + new ArrayList(); + + private int mSize; + + private int mCount; + + /** construct the aggregate object for a library */ + public LibraryAllocations(final String lib) { + mLibrary = lib; + } + + /** get the library name */ + public String getLibrary() { + return mLibrary; + } + + /** add a NativeAllocationInfo object to this aggregate object */ + public void addAllocation(NativeAllocationInfo info) { + mLibAllocations.add(info); + } + + /** get an iterator on the NativeAllocationInfo objects */ + public Iterator getAllocations() { + return mLibAllocations.iterator(); + } + + /** get a NativeAllocationInfo object by index */ + public NativeAllocationInfo getAllocation(int index) { + return mLibAllocations.get(index); + } + + /** returns the NativeAllocationInfo object count */ + public int getAllocationSize() { + return mLibAllocations.size(); + } + + /** returns the total allocation size */ + public int getSize() { + return mSize; + } + + /** returns the number of allocations */ + public int getCount() { + return mCount; + } + + /** + * compute the allocation count and size for allocation objects added + * through addAllocation(), and sort the objects by + * total allocation size. + */ + public void computeAllocationSizeAndCount() { + mSize = 0; + mCount = 0; + for (NativeAllocationInfo info : mLibAllocations) { + mCount += info.getAllocationCount(); + mSize += info.getAllocationCount() * info.getSize(); + } + Collections.sort(mLibAllocations, new Comparator() { + @Override + public int compare(NativeAllocationInfo o1, NativeAllocationInfo o2) { + return o2.getAllocationCount() * o2.getSize() - + o1.getAllocationCount() * o1.getSize(); + } + }); + } + } + + /** + * Create our control(s). + */ + @Override + protected Control createControl(Composite parent) { + + mDisplay = parent.getDisplay(); + + mBase = new Composite(parent, SWT.NONE); + GridLayout gl = new GridLayout(1, false); + gl.horizontalSpacing = 0; + gl.verticalSpacing = 0; + mBase.setLayout(gl); + mBase.setLayoutData(new GridData(GridData.FILL_BOTH)); + + // composite for + Composite tmp = new Composite(mBase, SWT.NONE); + tmp.setLayoutData(new GridData(GridData.FILL_HORIZONTAL)); + tmp.setLayout(gl = new GridLayout(2, false)); + gl.marginWidth = gl.marginHeight = 0; + + mFullUpdateButton = new Button(tmp, SWT.NONE); + mFullUpdateButton.setText("Full Update"); + mFullUpdateButton.addSelectionListener(new SelectionAdapter() { + @Override + public void widgetSelected(SelectionEvent e) { + mBackUpClientData = null; + mDisplayModeCombo.setEnabled(false); + mSaveButton.setEnabled(false); + emptyTables(); + // if we already have a stack call computation for this + // client + // we stop it + if (mStackCallThread != null && + mStackCallThread.getClientData() == mClientData) { + mStackCallThread.quit(); + mStackCallThread = null; + } + mLibraryAllocations.clear(); + Client client = getCurrentClient(); + if (client != null) { + client.requestNativeHeapInformation(); + } + } + }); + + mUpdateStatus = new Label(tmp, SWT.NONE); + mUpdateStatus.setLayoutData(new GridData(GridData.FILL_HORIZONTAL)); + + // top layout for the combos and oter controls on the right. + Composite top_layout = new Composite(mBase, SWT.NONE); + top_layout.setLayout(gl = new GridLayout(4, false)); + gl.marginWidth = gl.marginHeight = 0; + + new Label(top_layout, SWT.NONE).setText("Show:"); + + mAllocDisplayCombo = new Combo(top_layout, SWT.DROP_DOWN | SWT.READ_ONLY); + mAllocDisplayCombo.setLayoutData(new GridData( + GridData.HORIZONTAL_ALIGN_FILL | GridData.GRAB_HORIZONTAL)); + mAllocDisplayCombo.add("All Allocations"); + mAllocDisplayCombo.add("Pre-Zygote Allocations"); + mAllocDisplayCombo.add("Zygote Child Allocations (Z)"); + mAllocDisplayCombo.addSelectionListener(new SelectionAdapter() { + @Override + public void widgetSelected(SelectionEvent e) { + onAllocDisplayChange(); + } + }); + mAllocDisplayCombo.select(0); + + // separator + Label separator = new Label(top_layout, SWT.SEPARATOR | SWT.VERTICAL); + GridData gd; + separator.setLayoutData(gd = new GridData( + GridData.VERTICAL_ALIGN_FILL | GridData.GRAB_VERTICAL)); + gd.heightHint = 0; + gd.verticalSpan = 2; + + mSaveButton = new Button(top_layout, SWT.PUSH); + mSaveButton.setText("Save..."); + mSaveButton.setEnabled(false); + mSaveButton.addSelectionListener(new SelectionAdapter() { + @Override + public void widgetSelected(SelectionEvent e) { + FileDialog fileDialog = new FileDialog(mBase.getShell(), SWT.SAVE); + + fileDialog.setText("Save Allocations"); + fileDialog.setFileName("allocations.txt"); + + String fileName = fileDialog.open(); + if (fileName != null) { + saveAllocations(fileName); + } + } + }); + + /* + * TODO: either fix the diff mechanism or remove it altogether. + mDiffUpdateButton = new Button(top_layout, SWT.NONE); + mDiffUpdateButton.setText("Update && Diff"); + mDiffUpdateButton.addSelectionListener(new SelectionAdapter() { + @Override + public void widgetSelected(SelectionEvent e) { + // since this is an update and diff, we need to store the + // current list + // of mallocs + mBackUpAllocations.clear(); + mBackUpAllocations.addAll(mAllocations); + mBackUpClientData = mClientData; + mBackUpTotalMemory = mClientData.getTotalNativeMemory(); + + mDisplayModeCombo.setEnabled(false); + emptyTables(); + // if we already have a stack call computation for this + // client + // we stop it + if (mStackCallThread != null && + mStackCallThread.getClientData() == mClientData) { + mStackCallThread.quit(); + mStackCallThread = null; + } + mLibraryAllocations.clear(); + Client client = getCurrentClient(); + if (client != null) { + client.requestNativeHeapInformation(); + } + } + }); + */ + + Label l = new Label(top_layout, SWT.NONE); + l.setText("Display:"); + + mDisplayModeCombo = new Combo(top_layout, SWT.DROP_DOWN | SWT.READ_ONLY); + mDisplayModeCombo.setLayoutData(new GridData( + GridData.HORIZONTAL_ALIGN_FILL | GridData.GRAB_HORIZONTAL)); + mDisplayModeCombo.setItems(new String[] { "Allocation List", "By Libraries" }); + mDisplayModeCombo.select(0); + mDisplayModeCombo.addSelectionListener(new SelectionAdapter() { + @Override + public void widgetSelected(SelectionEvent e) { + switchDisplayMode(); + } + }); + mDisplayModeCombo.setEnabled(false); + + mSymbolsButton = new Button(top_layout, SWT.PUSH); + mSymbolsButton.setText("Load Symbols"); + mSymbolsButton.setEnabled(false); + + + // create a composite that will contains the actual content composites, + // in stack mode layout. + // This top level composite contains 2 other composites. + // * one for both Allocations and Libraries mode + // * one for flat mode (which is gone for now) + + mTopStackComposite = new Composite(mBase, SWT.NONE); + mTopStackComposite.setLayout(mTopStackLayout = new StackLayout()); + mTopStackComposite.setLayoutData(new GridData(GridData.FILL_BOTH)); + + // create 1st and 2nd modes + createTableDisplay(mTopStackComposite); + + mTopStackLayout.topControl = mTableModeControl; + mTopStackComposite.layout(); + + setUpdateStatus(NOT_SELECTED); + + // Work in progress + // TODO add image display of native heap. + //mImage = new Label(mBase, SWT.NONE); + + mBase.pack(); + + return mBase; + } + + /** + * Sets the focus to the proper control inside the panel. + */ + @Override + public void setFocus() { + // TODO + } + + + /** + * Sent when an existing client information changed. + *

+ * This is sent from a non UI thread. + * @param client the updated client. + * @param changeMask the bit mask describing the changed properties. It can contain + * any of the following values: {@link Client#CHANGE_INFO}, {@link Client#CHANGE_NAME} + * {@link Client#CHANGE_DEBUGGER_STATUS}, {@link Client#CHANGE_THREAD_MODE}, + * {@link Client#CHANGE_THREAD_DATA}, {@link Client#CHANGE_HEAP_MODE}, + * {@link Client#CHANGE_HEAP_DATA}, {@link Client#CHANGE_NATIVE_HEAP_DATA} + * + * @see IClientChangeListener#clientChanged(Client, int) + */ + @Override + public void clientChanged(final Client client, int changeMask) { + if (client == getCurrentClient()) { + if ((changeMask & Client.CHANGE_NATIVE_HEAP_DATA) == Client.CHANGE_NATIVE_HEAP_DATA) { + if (mBase.isDisposed()) + return; + + mBase.getDisplay().asyncExec(new Runnable() { + @Override + public void run() { + clientSelected(); + } + }); + } + } + } + + /** + * Sent when a new device is selected. The new device can be accessed + * with {@link #getCurrentDevice()}. + */ + @Override + public void deviceSelected() { + // pass + } + + /** + * Sent when a new client is selected. The new client can be accessed + * with {@link #getCurrentClient()}. + */ + @Override + public void clientSelected() { + if (mBase.isDisposed()) + return; + + Client client = getCurrentClient(); + + mDisplayModeCombo.setEnabled(false); + emptyTables(); + + Log.d("ddms", "NativeHeapPanel: changed " + client); + + if (client != null) { + ClientData cd = client.getClientData(); + mClientData = cd; + + // if (cd.getShowHeapUpdates()) + setUpdateStatus(ENABLED); + // else + // setUpdateStatus(NOT_ENABLED); + + initAllocationDisplay(); + + //renderBitmap(cd); + } else { + mClientData = null; + setUpdateStatus(NOT_SELECTED); + } + + mBase.pack(); + } + + /** + * Update the UI with the newly compute stack calls, unless the UI switched + * to a different client. + * + * @param cd the ClientData for which the stack call are being computed. + * @param count the current count of allocations for which the stack calls + * have been computed. + */ + @WorkerThread + public void updateAllocationStackCalls(ClientData cd, int count) { + // we have to check that the panel still shows the same clientdata than + // the thread is computing for. + if (cd == mClientData) { + + int total = mAllocations.size(); + + if (count == total) { + // we're done: do something + mDisplayModeCombo.setEnabled(true); + mSaveButton.setEnabled(true); + + mStackCallThread = null; + } else { + // work in progress, update the progress bar. +// mUiThread.setStatusLine("Computing stack call: " + count +// + "/" + total); + } + + // FIXME: attempt to only update when needed. + // Because the number of pages is not related to mAllocations.size() anymore + // due to pre-zygote/post-zygote display, update all the time. + // At some point we should remove the pages anyway, since it's getting computed + // really fast now. +// if ((mCurrentPage + 1) * DISPLAY_PER_PAGE == count +// || (count == total && mCurrentPage == mPageCount - 1)) { + try { + // get the current selection of the allocation + int index = mAllocationTable.getSelectionIndex(); + NativeAllocationInfo info = null; + + if (index != -1) { + info = (NativeAllocationInfo)mAllocationTable.getItem(index).getData(); + } + + // empty the table + emptyTables(); + + // fill it again + fillAllocationTable(); + + // reselect + mAllocationTable.setSelection(index); + + // display detail table if needed + if (info != null) { + fillDetailTable(info); + } + } catch (SWTException e) { + if (mAllocationTable.isDisposed()) { + // looks like the table is disposed. Let's ignore it. + } else { + throw e; + } + } + + } else { + // old client still running. doesn't really matter. + } + } + + @Override + protected void setTableFocusListener() { + addTableToFocusListener(mAllocationTable); + addTableToFocusListener(mLibraryTable); + addTableToFocusListener(mLibraryAllocationTable); + addTableToFocusListener(mDetailTable); + } + + protected void onAllocDisplayChange() { + mAllocDisplayMode = mAllocDisplayCombo.getSelectionIndex(); + + // create the new list + updateAllocDisplayList(); + + updateTotalMemoryDisplay(); + + // reset the ui. + mCurrentPage = 0; + updatePageUI(); + switchDisplayMode(); + } + + private void updateAllocDisplayList() { + mTotalSize = 0; + mDisplayedAllocations.clear(); + for (NativeAllocationInfo info : mAllocations) { + if (mAllocDisplayMode == ALLOC_DISPLAY_ALL || + (mAllocDisplayMode == ALLOC_DISPLAY_PRE_ZYGOTE ^ info.isZygoteChild())) { + mDisplayedAllocations.add(info); + mTotalSize += info.getSize() * info.getAllocationCount(); + } else { + // skip this item + continue; + } + } + + int count = mDisplayedAllocations.size(); + + mPageCount = count / DISPLAY_PER_PAGE; + + // need to add a page for the rest of the div + if ((count % DISPLAY_PER_PAGE) > 0) { + mPageCount++; + } + } + + private void updateTotalMemoryDisplay() { + switch (mAllocDisplayMode) { + case ALLOC_DISPLAY_ALL: + mTotalMemoryLabel.setText(String.format("Total Memory: %1$s Bytes", + sFormatter.format(mTotalSize))); + break; + case ALLOC_DISPLAY_PRE_ZYGOTE: + mTotalMemoryLabel.setText(String.format("Zygote Memory: %1$s Bytes", + sFormatter.format(mTotalSize))); + break; + case ALLOC_DISPLAY_POST_ZYGOTE: + mTotalMemoryLabel.setText(String.format("Post-zygote Memory: %1$s Bytes", + sFormatter.format(mTotalSize))); + break; + } + } + + + private void switchDisplayMode() { + switch (mDisplayModeCombo.getSelectionIndex()) { + case 0: {// allocations + mTopStackLayout.topControl = mTableModeControl; + mAllocationStackLayout.topControl = mAllocationModeTop; + mAllocationStackComposite.layout(); + mTopStackComposite.layout(); + emptyTables(); + fillAllocationTable(); + } + break; + case 1: {// libraries + mTopStackLayout.topControl = mTableModeControl; + mAllocationStackLayout.topControl = mLibraryModeTopControl; + mAllocationStackComposite.layout(); + mTopStackComposite.layout(); + emptyTables(); + fillLibraryTable(); + } + break; + } + } + + private void initAllocationDisplay() { + if (mStackCallThread != null) { + mStackCallThread.quit(); + } + + mAllocations.clear(); + mAllocations.addAll(mClientData.getNativeAllocationList()); + + updateAllocDisplayList(); + + // if we have a previous clientdata and it matches the current one. we + // do a diff between the new list and the old one. + if (mBackUpClientData != null && mBackUpClientData == mClientData) { + + ArrayList add = new ArrayList(); + + // we go through the list of NativeAllocationInfo in the new list and check if + // there's one with the same exact data (size, allocation, count and + // stackcall addresses) in the old list. + // if we don't find any, we add it to the "add" list + for (NativeAllocationInfo mi : mAllocations) { + boolean found = false; + for (NativeAllocationInfo old_mi : mBackUpAllocations) { + if (mi.equals(old_mi)) { + found = true; + break; + } + } + if (found == false) { + add.add(mi); + } + } + + // put the result in mAllocations + mAllocations.clear(); + mAllocations.addAll(add); + + // display the difference in memory usage. This is computed + // calculating the memory usage of the objects in mAllocations. + int count = 0; + for (NativeAllocationInfo allocInfo : mAllocations) { + count += allocInfo.getSize() * allocInfo.getAllocationCount(); + } + + mTotalMemoryLabel.setText(String.format("Memory Difference: %1$s Bytes", + sFormatter.format(count))); + } + else { + // display the full memory usage + updateTotalMemoryDisplay(); + //mDiffUpdateButton.setEnabled(mClientData.getTotalNativeMemory() > 0); + } + mTotalMemoryLabel.pack(); + + // update the page ui + mDisplayModeCombo.select(0); + + mLibraryAllocations.clear(); + + // reset to first page + mCurrentPage = 0; + + // update the label + updatePageUI(); + + // now fill the allocation Table with the current page + switchDisplayMode(); + + // start the thread to compute the stack calls + if (mAllocations.size() > 0) { + mStackCallThread = new StackCallThread(mClientData); + mStackCallThread.start(); + } + } + + private void updatePageUI() { + + // set the label and pack to update the layout, otherwise + // the label will be cut off if the new size is bigger + if (mPageCount == 0) { + mPageLabel.setText("0 of 0 allocations."); + } else { + StringBuffer buffer = new StringBuffer(); + // get our starting index + int start = (mCurrentPage * DISPLAY_PER_PAGE) + 1; + // end index, taking into account the last page can be half full + int count = mDisplayedAllocations.size(); + int end = Math.min(start + DISPLAY_PER_PAGE - 1, count); + buffer.append(sFormatter.format(start)); + buffer.append(" - "); + buffer.append(sFormatter.format(end)); + buffer.append(" of "); + buffer.append(sFormatter.format(count)); + buffer.append(" allocations."); + mPageLabel.setText(buffer.toString()); + } + + // handle the button enabled state. + mPagePreviousButton.setEnabled(mCurrentPage > 0); + // reminder: mCurrentPage starts at 0. + mPageNextButton.setEnabled(mCurrentPage < mPageCount - 1); + + mPageLabel.pack(); + mPageUIComposite.pack(); + + } + + private void fillAllocationTable() { + // get the count + int count = mDisplayedAllocations.size(); + + // get our starting index + int start = mCurrentPage * DISPLAY_PER_PAGE; + + // loop for DISPLAY_PER_PAGE or till we reach count + int end = start + DISPLAY_PER_PAGE; + + for (int i = start; i < end && i < count; i++) { + NativeAllocationInfo info = mDisplayedAllocations.get(i); + + TableItem item = null; + + if (mAllocDisplayMode == ALLOC_DISPLAY_ALL) { + item = new TableItem(mAllocationTable, SWT.NONE); + item.setText(0, (info.isZygoteChild() ? "Z " : "") + + sFormatter.format(info.getSize() * info.getAllocationCount())); + item.setText(1, sFormatter.format(info.getAllocationCount())); + item.setText(2, sFormatter.format(info.getSize())); + } else if (mAllocDisplayMode == ALLOC_DISPLAY_PRE_ZYGOTE ^ info.isZygoteChild()) { + item = new TableItem(mAllocationTable, SWT.NONE); + item.setText(0, sFormatter.format(info.getSize() * info.getAllocationCount())); + item.setText(1, sFormatter.format(info.getAllocationCount())); + item.setText(2, sFormatter.format(info.getSize())); + } else { + // skip this item + continue; + } + + item.setData(info); + + NativeStackCallInfo bti = info.getRelevantStackCallInfo(); + if (bti != null) { + String lib = bti.getLibraryName(); + String method = bti.getMethodName(); + String source = bti.getSourceFile(); + if (lib != null) + item.setText(3, lib); + if (method != null) + item.setText(4, method); + if (source != null) + item.setText(5, source); + } + } + } + + private void fillLibraryTable() { + // fill the library table + sortAllocationsPerLibrary(); + + for (LibraryAllocations liballoc : mLibraryAllocations) { + if (liballoc != null) { + TableItem item = new TableItem(mLibraryTable, SWT.NONE); + String lib = liballoc.getLibrary(); + item.setText(0, lib != null ? lib : ""); + item.setText(1, sFormatter.format(liballoc.getSize())); + item.setText(2, sFormatter.format(liballoc.getCount())); + } + } + } + + private void fillLibraryAllocationTable() { + mLibraryAllocationTable.removeAll(); + mDetailTable.removeAll(); + int index = mLibraryTable.getSelectionIndex(); + if (index != -1) { + LibraryAllocations liballoc = mLibraryAllocations.get(index); + // start a thread that will fill table 10 at a time to keep the ui + // responsive, but first we kill the previous one if there was one + if (mFillTableThread != null) { + mFillTableThread.quit(); + } + mFillTableThread = new FillTableThread(liballoc, + liballoc.getAllocationSize()); + mFillTableThread.start(); + } + } + + public void updateLibraryAllocationTable(LibraryAllocations liballoc, + int start, int end) { + try { + if (mLibraryTable.isDisposed() == false) { + int index = mLibraryTable.getSelectionIndex(); + if (index != -1) { + LibraryAllocations newliballoc = mLibraryAllocations.get( + index); + if (newliballoc == liballoc) { + int count = liballoc.getAllocationSize(); + for (int i = start; i < end && i < count; i++) { + NativeAllocationInfo info = liballoc.getAllocation(i); + + TableItem item = new TableItem( + mLibraryAllocationTable, SWT.NONE); + item.setText(0, sFormatter.format( + info.getSize() * info.getAllocationCount())); + item.setText(1, sFormatter.format(info.getAllocationCount())); + item.setText(2, sFormatter.format(info.getSize())); + + NativeStackCallInfo stackCallInfo = info.getRelevantStackCallInfo(); + if (stackCallInfo != null) { + item.setText(3, stackCallInfo.getMethodName()); + } + } + } else { + // we should quit the thread + if (mFillTableThread != null) { + mFillTableThread.quit(); + mFillTableThread = null; + } + } + } + } + } catch (SWTException e) { + Log.e("ddms", "error when updating the library allocation table"); + } + } + + private void fillDetailTable(final NativeAllocationInfo mi) { + mDetailTable.removeAll(); + mDetailTable.setRedraw(false); + + try { + // populate the detail Table with the back trace + List addresses = mi.getStackCallAddresses(); + List resolvedStackCall = mi.getResolvedStackCall(); + + if (resolvedStackCall == null) { + return; + } + + for (int i = 0 ; i < resolvedStackCall.size(); i++) { + if (addresses.get(i) == null || addresses.get(i).longValue() == 0) { + continue; + } + + long addr = addresses.get(i).longValue(); + NativeStackCallInfo source = resolvedStackCall.get(i); + + TableItem item = new TableItem(mDetailTable, SWT.NONE); + item.setText(0, String.format("%08x", addr)); //$NON-NLS-1$ + + String libraryName = source.getLibraryName(); + String methodName = source.getMethodName(); + String sourceFile = source.getSourceFile(); + int lineNumber = source.getLineNumber(); + + if (libraryName != null) + item.setText(1, libraryName); + if (methodName != null) + item.setText(2, methodName); + if (sourceFile != null) + item.setText(3, sourceFile); + if (lineNumber != -1) + item.setText(4, Integer.toString(lineNumber)); + } + } finally { + mDetailTable.setRedraw(true); + } + } + + /* + * Are updates enabled? + */ + private void setUpdateStatus(int status) { + switch (status) { + case NOT_SELECTED: + mUpdateStatus.setText("Select a client to see heap info"); + mAllocDisplayCombo.setEnabled(false); + mFullUpdateButton.setEnabled(false); + //mDiffUpdateButton.setEnabled(false); + break; + case NOT_ENABLED: + mUpdateStatus.setText("Heap updates are " + "NOT ENABLED for this client"); + mAllocDisplayCombo.setEnabled(false); + mFullUpdateButton.setEnabled(false); + //mDiffUpdateButton.setEnabled(false); + break; + case ENABLED: + mUpdateStatus.setText("Press 'Full Update' to retrieve " + "latest data"); + mAllocDisplayCombo.setEnabled(true); + mFullUpdateButton.setEnabled(true); + //mDiffUpdateButton.setEnabled(true); + break; + default: + throw new RuntimeException(); + } + + mUpdateStatus.pack(); + } + + /** + * Create the Table display. This includes a "detail" Table in the bottom + * half and 2 modes in the top half: allocation Table and + * library+allocations Tables. + * + * @param base the top parent to create the display into + */ + private void createTableDisplay(Composite base) { + final int minPanelWidth = 60; + + final IPreferenceStore prefs = DdmUiPreferences.getStore(); + + // top level composite for mode 1 & 2 + mTableModeControl = new Composite(base, SWT.NONE); + GridLayout gl = new GridLayout(1, false); + gl.marginLeft = gl.marginRight = gl.marginTop = gl.marginBottom = 0; + mTableModeControl.setLayout(gl); + mTableModeControl.setLayoutData(new GridData(GridData.FILL_BOTH)); + + mTotalMemoryLabel = new Label(mTableModeControl, SWT.NONE); + mTotalMemoryLabel.setLayoutData(new GridData(GridData.FILL_HORIZONTAL)); + mTotalMemoryLabel.setText("Total Memory: 0 Bytes"); + + // the top half of these modes is dynamic + + final Composite sash_composite = new Composite(mTableModeControl, + SWT.NONE); + sash_composite.setLayout(new FormLayout()); + sash_composite.setLayoutData(new GridData(GridData.FILL_BOTH)); + + // create the stacked composite + mAllocationStackComposite = new Composite(sash_composite, SWT.NONE); + mAllocationStackLayout = new StackLayout(); + mAllocationStackComposite.setLayout(mAllocationStackLayout); + mAllocationStackComposite.setLayoutData(new GridData( + GridData.FILL_BOTH)); + + // create the top half for mode 1 + createAllocationTopHalf(mAllocationStackComposite); + + // create the top half for mode 2 + createLibraryTopHalf(mAllocationStackComposite); + + final Sash sash = new Sash(sash_composite, SWT.HORIZONTAL); + + // bottom half of these modes is the same: detail table + createDetailTable(sash_composite); + + // init value for stack + mAllocationStackLayout.topControl = mAllocationModeTop; + + // form layout data + FormData data = new FormData(); + data.top = new FormAttachment(mTotalMemoryLabel, 0); + data.bottom = new FormAttachment(sash, 0); + data.left = new FormAttachment(0, 0); + data.right = new FormAttachment(100, 0); + mAllocationStackComposite.setLayoutData(data); + + final FormData sashData = new FormData(); + if (prefs != null && prefs.contains(PREFS_ALLOCATION_SASH)) { + sashData.top = new FormAttachment(0, + prefs.getInt(PREFS_ALLOCATION_SASH)); + } else { + sashData.top = new FormAttachment(50, 0); // 50% across + } + sashData.left = new FormAttachment(0, 0); + sashData.right = new FormAttachment(100, 0); + sash.setLayoutData(sashData); + + data = new FormData(); + data.top = new FormAttachment(sash, 0); + data.bottom = new FormAttachment(100, 0); + data.left = new FormAttachment(0, 0); + data.right = new FormAttachment(100, 0); + mDetailTable.setLayoutData(data); + + // allow resizes, but cap at minPanelWidth + sash.addListener(SWT.Selection, new Listener() { + @Override + public void handleEvent(Event e) { + Rectangle sashRect = sash.getBounds(); + Rectangle panelRect = sash_composite.getClientArea(); + int bottom = panelRect.height - sashRect.height - minPanelWidth; + e.y = Math.max(Math.min(e.y, bottom), minPanelWidth); + if (e.y != sashRect.y) { + sashData.top = new FormAttachment(0, e.y); + prefs.setValue(PREFS_ALLOCATION_SASH, e.y); + sash_composite.layout(); + } + } + }); + } + + private void createDetailTable(Composite base) { + + final IPreferenceStore prefs = DdmUiPreferences.getStore(); + + mDetailTable = new Table(base, SWT.MULTI | SWT.FULL_SELECTION); + mDetailTable.setLayoutData(new GridData(GridData.FILL_BOTH)); + mDetailTable.setHeaderVisible(true); + mDetailTable.setLinesVisible(true); + + TableHelper.createTableColumn(mDetailTable, "Address", SWT.RIGHT, + "00000000", PREFS_DETAIL_ADDRESS, prefs); //$NON-NLS-1$ + TableHelper.createTableColumn(mDetailTable, "Library", SWT.LEFT, + "abcdefghijklmnopqrst", PREFS_DETAIL_LIBRARY, prefs); //$NON-NLS-1$ + TableHelper.createTableColumn(mDetailTable, "Method", SWT.LEFT, + "abcdefghijklmnopqrst", PREFS_DETAIL_METHOD, prefs); //$NON-NLS-1$ + TableHelper.createTableColumn(mDetailTable, "File", SWT.LEFT, + "abcdefghijklmnopqrstuvwxyz", PREFS_DETAIL_FILE, prefs); //$NON-NLS-1$ + TableHelper.createTableColumn(mDetailTable, "Line", SWT.RIGHT, + "9,999", PREFS_DETAIL_LINE, prefs); //$NON-NLS-1$ + } + + private void createAllocationTopHalf(Composite b) { + final IPreferenceStore prefs = DdmUiPreferences.getStore(); + + Composite base = new Composite(b, SWT.NONE); + mAllocationModeTop = base; + GridLayout gl = new GridLayout(1, false); + gl.marginLeft = gl.marginRight = gl.marginTop = gl.marginBottom = 0; + gl.verticalSpacing = 0; + base.setLayout(gl); + base.setLayoutData(new GridData(GridData.FILL_BOTH)); + + // horizontal layout for memory total and pages UI + mPageUIComposite = new Composite(base, SWT.NONE); + mPageUIComposite.setLayoutData(new GridData( + GridData.HORIZONTAL_ALIGN_BEGINNING)); + gl = new GridLayout(3, false); + gl.marginLeft = gl.marginRight = gl.marginTop = gl.marginBottom = 0; + gl.horizontalSpacing = 0; + mPageUIComposite.setLayout(gl); + + // Page UI + mPagePreviousButton = new Button(mPageUIComposite, SWT.NONE); + mPagePreviousButton.setText("<"); + mPagePreviousButton.addSelectionListener(new SelectionAdapter() { + @Override + public void widgetSelected(SelectionEvent e) { + mCurrentPage--; + updatePageUI(); + emptyTables(); + fillAllocationTable(); + } + }); + + mPageNextButton = new Button(mPageUIComposite, SWT.NONE); + mPageNextButton.setText(">"); + mPageNextButton.addSelectionListener(new SelectionAdapter() { + @Override + public void widgetSelected(SelectionEvent e) { + mCurrentPage++; + updatePageUI(); + emptyTables(); + fillAllocationTable(); + } + }); + + mPageLabel = new Label(mPageUIComposite, SWT.NONE); + mPageLabel.setLayoutData(new GridData(GridData.FILL_HORIZONTAL)); + + updatePageUI(); + + mAllocationTable = new Table(base, SWT.MULTI | SWT.FULL_SELECTION); + mAllocationTable.setLayoutData(new GridData(GridData.FILL_BOTH)); + mAllocationTable.setHeaderVisible(true); + mAllocationTable.setLinesVisible(true); + + TableHelper.createTableColumn(mAllocationTable, "Total", SWT.RIGHT, + "9,999,999", PREFS_ALLOC_TOTAL, prefs); //$NON-NLS-1$ + TableHelper.createTableColumn(mAllocationTable, "Count", SWT.RIGHT, + "9,999", PREFS_ALLOC_COUNT, prefs); //$NON-NLS-1$ + TableHelper.createTableColumn(mAllocationTable, "Size", SWT.RIGHT, + "999,999", PREFS_ALLOC_SIZE, prefs); //$NON-NLS-1$ + TableHelper.createTableColumn(mAllocationTable, "Library", SWT.LEFT, + "abcdefghijklmnopqrst", PREFS_ALLOC_LIBRARY, prefs); //$NON-NLS-1$ + TableHelper.createTableColumn(mAllocationTable, "Method", SWT.LEFT, + "abcdefghijklmnopqrst", PREFS_ALLOC_METHOD, prefs); //$NON-NLS-1$ + TableHelper.createTableColumn(mAllocationTable, "File", SWT.LEFT, + "abcdefghijklmnopqrstuvwxyz", PREFS_ALLOC_FILE, prefs); //$NON-NLS-1$ + + mAllocationTable.addSelectionListener(new SelectionAdapter() { + @Override + public void widgetSelected(SelectionEvent e) { + // get the selection index + int index = mAllocationTable.getSelectionIndex(); + if (index >= 0 && index < mAllocationTable.getItemCount()) { + TableItem item = mAllocationTable.getItem(index); + if (item != null && item.getData() instanceof NativeAllocationInfo) { + fillDetailTable((NativeAllocationInfo)item.getData()); + } + } + } + }); + } + + private void createLibraryTopHalf(Composite base) { + final int minPanelWidth = 60; + + final IPreferenceStore prefs = DdmUiPreferences.getStore(); + + // create a composite that'll contain 2 tables horizontally + final Composite top = new Composite(base, SWT.NONE); + mLibraryModeTopControl = top; + top.setLayout(new FormLayout()); + top.setLayoutData(new GridData(GridData.FILL_BOTH)); + + // first table: library + mLibraryTable = new Table(top, SWT.MULTI | SWT.FULL_SELECTION); + mLibraryTable.setLayoutData(new GridData(GridData.FILL_BOTH)); + mLibraryTable.setHeaderVisible(true); + mLibraryTable.setLinesVisible(true); + + TableHelper.createTableColumn(mLibraryTable, "Library", SWT.LEFT, + "abcdefghijklmnopqrstuvwxyz", PREFS_LIB_LIBRARY, prefs); //$NON-NLS-1$ + TableHelper.createTableColumn(mLibraryTable, "Size", SWT.RIGHT, + "9,999,999", PREFS_LIB_SIZE, prefs); //$NON-NLS-1$ + TableHelper.createTableColumn(mLibraryTable, "Count", SWT.RIGHT, + "9,999", PREFS_LIB_COUNT, prefs); //$NON-NLS-1$ + + mLibraryTable.addSelectionListener(new SelectionAdapter() { + @Override + public void widgetSelected(SelectionEvent e) { + fillLibraryAllocationTable(); + } + }); + + final Sash sash = new Sash(top, SWT.VERTICAL); + + // 2nd table: allocation per library + mLibraryAllocationTable = new Table(top, SWT.MULTI | SWT.FULL_SELECTION); + mLibraryAllocationTable.setLayoutData(new GridData(GridData.FILL_BOTH)); + mLibraryAllocationTable.setHeaderVisible(true); + mLibraryAllocationTable.setLinesVisible(true); + + TableHelper.createTableColumn(mLibraryAllocationTable, "Total", + SWT.RIGHT, "9,999,999", PREFS_LIBALLOC_TOTAL, prefs); //$NON-NLS-1$ + TableHelper.createTableColumn(mLibraryAllocationTable, "Count", + SWT.RIGHT, "9,999", PREFS_LIBALLOC_COUNT, prefs); //$NON-NLS-1$ + TableHelper.createTableColumn(mLibraryAllocationTable, "Size", + SWT.RIGHT, "999,999", PREFS_LIBALLOC_SIZE, prefs); //$NON-NLS-1$ + TableHelper.createTableColumn(mLibraryAllocationTable, "Method", + SWT.LEFT, "abcdefghijklmnopqrst", PREFS_LIBALLOC_METHOD, prefs); //$NON-NLS-1$ + + mLibraryAllocationTable.addSelectionListener(new SelectionAdapter() { + @Override + public void widgetSelected(SelectionEvent e) { + // get the index of the selection in the library table + int index1 = mLibraryTable.getSelectionIndex(); + // get the index in the library allocation table + int index2 = mLibraryAllocationTable.getSelectionIndex(); + // get the MallocInfo object + if (index1 != -1 && index2 != -1) { + LibraryAllocations liballoc = mLibraryAllocations.get(index1); + NativeAllocationInfo info = liballoc.getAllocation(index2); + fillDetailTable(info); + } + } + }); + + // form layout data + FormData data = new FormData(); + data.top = new FormAttachment(0, 0); + data.bottom = new FormAttachment(100, 0); + data.left = new FormAttachment(0, 0); + data.right = new FormAttachment(sash, 0); + mLibraryTable.setLayoutData(data); + + final FormData sashData = new FormData(); + if (prefs != null && prefs.contains(PREFS_LIBRARY_SASH)) { + sashData.left = new FormAttachment(0, + prefs.getInt(PREFS_LIBRARY_SASH)); + } else { + sashData.left = new FormAttachment(50, 0); + } + sashData.bottom = new FormAttachment(100, 0); + sashData.top = new FormAttachment(0, 0); // 50% across + sash.setLayoutData(sashData); + + data = new FormData(); + data.top = new FormAttachment(0, 0); + data.bottom = new FormAttachment(100, 0); + data.left = new FormAttachment(sash, 0); + data.right = new FormAttachment(100, 0); + mLibraryAllocationTable.setLayoutData(data); + + // allow resizes, but cap at minPanelWidth + sash.addListener(SWT.Selection, new Listener() { + @Override + public void handleEvent(Event e) { + Rectangle sashRect = sash.getBounds(); + Rectangle panelRect = top.getClientArea(); + int right = panelRect.width - sashRect.width - minPanelWidth; + e.x = Math.max(Math.min(e.x, right), minPanelWidth); + if (e.x != sashRect.x) { + sashData.left = new FormAttachment(0, e.x); + prefs.setValue(PREFS_LIBRARY_SASH, e.y); + top.layout(); + } + } + }); + } + + private void emptyTables() { + mAllocationTable.removeAll(); + mLibraryTable.removeAll(); + mLibraryAllocationTable.removeAll(); + mDetailTable.removeAll(); + } + + private void sortAllocationsPerLibrary() { + if (mClientData != null) { + mLibraryAllocations.clear(); + + // create a hash map of LibraryAllocations to access aggregate + // objects already created + HashMap libcache = + new HashMap(); + + // get the allocation count + int count = mDisplayedAllocations.size(); + for (int i = 0; i < count; i++) { + NativeAllocationInfo allocInfo = mDisplayedAllocations.get(i); + + NativeStackCallInfo stackCallInfo = allocInfo.getRelevantStackCallInfo(); + if (stackCallInfo != null) { + String libraryName = stackCallInfo.getLibraryName(); + LibraryAllocations liballoc = libcache.get(libraryName); + if (liballoc == null) { + // didn't find a library allocation object already + // created so we create one + liballoc = new LibraryAllocations(libraryName); + // add it to the cache + libcache.put(libraryName, liballoc); + // add it to the list + mLibraryAllocations.add(liballoc); + } + // add the MallocInfo object to it. + liballoc.addAllocation(allocInfo); + } + } + // now that the list is created, we need to compute the size and + // sort it by size. This will also sort the MallocInfo objects + // inside each LibraryAllocation objects. + for (LibraryAllocations liballoc : mLibraryAllocations) { + liballoc.computeAllocationSizeAndCount(); + } + + // now we sort it + Collections.sort(mLibraryAllocations, + new Comparator() { + @Override + public int compare(LibraryAllocations o1, + LibraryAllocations o2) { + return o2.getSize() - o1.getSize(); + } + }); + } + } + + private void renderBitmap(ClientData cd) { + byte[] pixData; + + // Atomically get and clear the heap data. + synchronized (cd) { + if (serializeHeapData(cd.getVmHeapData()) == false) { + // no change, we return. + return; + } + + pixData = getSerializedData(); + + ImageData id = createLinearHeapImage(pixData, 200, mMapPalette); + Image image = new Image(mBase.getDisplay(), id); + mImage.setImage(image); + mImage.pack(true); + } + } + + /* + * Create color palette for map. Set up titles for legend. + */ + private static PaletteData createPalette() { + RGB colors[] = new RGB[NUM_PALETTE_ENTRIES]; + colors[0] + = new RGB(192, 192, 192); // non-heap pixels are gray + mMapLegend[0] + = "(heap expansion area)"; + + colors[1] + = new RGB(0, 0, 0); // free chunks are black + mMapLegend[1] + = "free"; + + colors[HeapSegmentElement.KIND_OBJECT + 2] + = new RGB(0, 0, 255); // objects are blue + mMapLegend[HeapSegmentElement.KIND_OBJECT + 2] + = "data object"; + + colors[HeapSegmentElement.KIND_CLASS_OBJECT + 2] + = new RGB(0, 255, 0); // class objects are green + mMapLegend[HeapSegmentElement.KIND_CLASS_OBJECT + 2] + = "class object"; + + colors[HeapSegmentElement.KIND_ARRAY_1 + 2] + = new RGB(255, 0, 0); // byte/bool arrays are red + mMapLegend[HeapSegmentElement.KIND_ARRAY_1 + 2] + = "1-byte array (byte[], boolean[])"; + + colors[HeapSegmentElement.KIND_ARRAY_2 + 2] + = new RGB(255, 128, 0); // short/char arrays are orange + mMapLegend[HeapSegmentElement.KIND_ARRAY_2 + 2] + = "2-byte array (short[], char[])"; + + colors[HeapSegmentElement.KIND_ARRAY_4 + 2] + = new RGB(255, 255, 0); // obj/int/float arrays are yellow + mMapLegend[HeapSegmentElement.KIND_ARRAY_4 + 2] + = "4-byte array (object[], int[], float[])"; + + colors[HeapSegmentElement.KIND_ARRAY_8 + 2] + = new RGB(255, 128, 128); // long/double arrays are pink + mMapLegend[HeapSegmentElement.KIND_ARRAY_8 + 2] + = "8-byte array (long[], double[])"; + + colors[HeapSegmentElement.KIND_UNKNOWN + 2] + = new RGB(255, 0, 255); // unknown objects are cyan + mMapLegend[HeapSegmentElement.KIND_UNKNOWN + 2] + = "unknown object"; + + colors[HeapSegmentElement.KIND_NATIVE + 2] + = new RGB(64, 64, 64); // native objects are dark gray + mMapLegend[HeapSegmentElement.KIND_NATIVE + 2] + = "non-Java object"; + + return new PaletteData(colors); + } + + private void saveAllocations(String fileName) { + try { + PrintWriter out = new PrintWriter(new BufferedWriter(new FileWriter(fileName))); + + for (NativeAllocationInfo alloc : mAllocations) { + out.println(alloc.toString()); + } + out.close(); + } catch (IOException e) { + Log.e("Native", e); + } + } +} diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/Panel.java b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/Panel.java new file mode 100644 index 00000000..d910cc74 --- /dev/null +++ b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/Panel.java @@ -0,0 +1,49 @@ +/* + * Copyright (C) 2007 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.ddmuilib; + +import org.eclipse.swt.widgets.Composite; +import org.eclipse.swt.widgets.Control; + + +/** + * Base class for our information panels. + */ +public abstract class Panel { + + public final Control createPanel(Composite parent) { + Control panelControl = createControl(parent); + + postCreation(); + + return panelControl; + } + + protected abstract void postCreation(); + + /** + * Creates a control capable of displaying some information. This is + * called once, when the application is initializing, from the UI thread. + */ + protected abstract Control createControl(Composite parent); + + /** + * Sets the focus to the proper control inside the panel. + */ + public abstract void setFocus(); +} + diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/PortFieldEditor.java b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/PortFieldEditor.java new file mode 100644 index 00000000..533372e8 --- /dev/null +++ b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/PortFieldEditor.java @@ -0,0 +1,73 @@ +/* + * Copyright (C) 2007 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.ddmuilib; + +import org.eclipse.jface.preference.IntegerFieldEditor; +import org.eclipse.swt.widgets.Composite; + +/** + * Edit an integer field, validating it as a port number. + */ +public class PortFieldEditor extends IntegerFieldEditor { + + public boolean mRecursiveCheck = false; + + public PortFieldEditor(String name, String label, Composite parent) { + super(name, label, parent); + setValidateStrategy(VALIDATE_ON_KEY_STROKE); + } + + /* + * Get the current value of the field, as an integer. + */ + public int getCurrentValue() { + int val; + try { + val = Integer.parseInt(getStringValue()); + } + catch (NumberFormatException nfe) { + val = -1; + } + return val; + } + + /* + * Check the validity of the field. + */ + @Override + protected boolean checkState() { + if (super.checkState() == false) { + return false; + } + //Log.i("ddms", "check state " + getStringValue()); + boolean err = false; + int val = getCurrentValue(); + if (val < 1024 || val > 32767) { + setErrorMessage("Port must be between 1024 and 32767"); + err = true; + } else { + setErrorMessage(null); + err = false; + } + showErrorMessage(); + return !err; + } + + protected void updateCheckState(PortFieldEditor pfe) { + pfe.refreshValidState(); + } +} diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/ScreenShotDialog.java b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/ScreenShotDialog.java new file mode 100644 index 00000000..b0f885ad --- /dev/null +++ b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/ScreenShotDialog.java @@ -0,0 +1,350 @@ +/* + * Copyright (C) 2007 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.ddmuilib; + +import com.android.ddmlib.AdbCommandRejectedException; +import com.android.ddmlib.IDevice; +import com.android.ddmlib.Log; +import com.android.ddmlib.RawImage; +import com.android.ddmlib.TimeoutException; + +import org.eclipse.swt.SWT; +import org.eclipse.swt.SWTException; +import org.eclipse.swt.dnd.Clipboard; +import org.eclipse.swt.dnd.ImageTransfer; +import org.eclipse.swt.dnd.Transfer; +import org.eclipse.swt.events.SelectionAdapter; +import org.eclipse.swt.events.SelectionEvent; +import org.eclipse.swt.graphics.Image; +import org.eclipse.swt.graphics.ImageData; +import org.eclipse.swt.graphics.PaletteData; +import org.eclipse.swt.layout.GridData; +import org.eclipse.swt.layout.GridLayout; +import org.eclipse.swt.widgets.Button; +import org.eclipse.swt.widgets.Dialog; +import org.eclipse.swt.widgets.Display; +import org.eclipse.swt.widgets.FileDialog; +import org.eclipse.swt.widgets.Label; +import org.eclipse.swt.widgets.Shell; + +import java.io.File; +import java.io.IOException; +import java.util.Calendar; + + +/** + * Gather a screen shot from the device and save it to a file. + */ +public class ScreenShotDialog extends Dialog { + + private Label mBusyLabel; + private Label mImageLabel; + private Button mSave; + private IDevice mDevice; + private RawImage mRawImage; + private Clipboard mClipboard; + + /** Number of 90 degree rotations applied to the current image */ + private int mRotateCount = 0; + + /** + * Create with default style. + */ + public ScreenShotDialog(Shell parent) { + this(parent, SWT.DIALOG_TRIM | SWT.APPLICATION_MODAL); + mClipboard = new Clipboard(parent.getDisplay()); + } + + /** + * Create with app-defined style. + */ + public ScreenShotDialog(Shell parent, int style) { + super(parent, style); + } + + /** + * Prepare and display the dialog. + * @param device The {@link IDevice} from which to get the screenshot. + */ + public void open(IDevice device) { + mDevice = device; + + Shell parent = getParent(); + Shell shell = new Shell(parent, getStyle()); + shell.setText("Device Screen Capture"); + + createContents(shell); + shell.pack(); + shell.open(); + + updateDeviceImage(shell); + + Display display = parent.getDisplay(); + while (!shell.isDisposed()) { + if (!display.readAndDispatch()) + display.sleep(); + } + + } + + /* + * Create the screen capture dialog contents. + */ + private void createContents(final Shell shell) { + GridData data; + + final int colCount = 5; + + shell.setLayout(new GridLayout(colCount, true)); + + // "refresh" button + Button refresh = new Button(shell, SWT.PUSH); + refresh.setText("Refresh"); + data = new GridData(GridData.HORIZONTAL_ALIGN_CENTER); + data.widthHint = 80; + refresh.setLayoutData(data); + refresh.addSelectionListener(new SelectionAdapter() { + @Override + public void widgetSelected(SelectionEvent e) { + updateDeviceImage(shell); + // RawImage only allows us to rotate the image 90 degrees at the time, + // so to preserve the current rotation we must call getRotated() + // the same number of times the user has done it manually. + // TODO: improve the RawImage class. + for (int i=0; i < mRotateCount; i++) { + mRawImage = mRawImage.getRotated(); + } + updateImageDisplay(shell); + } + }); + + // "rotate" button + Button rotate = new Button(shell, SWT.PUSH); + rotate.setText("Rotate"); + data = new GridData(GridData.HORIZONTAL_ALIGN_CENTER); + data.widthHint = 80; + rotate.setLayoutData(data); + rotate.addSelectionListener(new SelectionAdapter() { + @Override + public void widgetSelected(SelectionEvent e) { + if (mRawImage != null) { + mRotateCount = (mRotateCount + 1) % 4; + mRawImage = mRawImage.getRotated(); + updateImageDisplay(shell); + } + } + }); + + // "save" button + mSave = new Button(shell, SWT.PUSH); + mSave.setText("Save"); + data = new GridData(GridData.HORIZONTAL_ALIGN_CENTER); + data.widthHint = 80; + mSave.setLayoutData(data); + mSave.addSelectionListener(new SelectionAdapter() { + @Override + public void widgetSelected(SelectionEvent e) { + saveImage(shell); + } + }); + + Button copy = new Button(shell, SWT.PUSH); + copy.setText("Copy"); + copy.setToolTipText("Copy the screenshot to the clipboard"); + data = new GridData(GridData.HORIZONTAL_ALIGN_CENTER); + data.widthHint = 80; + copy.setLayoutData(data); + copy.addSelectionListener(new SelectionAdapter() { + @Override + public void widgetSelected(SelectionEvent e) { + copy(); + } + }); + + + // "done" button + Button done = new Button(shell, SWT.PUSH); + done.setText("Done"); + data = new GridData(GridData.HORIZONTAL_ALIGN_CENTER); + data.widthHint = 80; + done.setLayoutData(data); + done.addSelectionListener(new SelectionAdapter() { + @Override + public void widgetSelected(SelectionEvent e) { + shell.close(); + } + }); + + // title/"capturing" label + mBusyLabel = new Label(shell, SWT.NONE); + mBusyLabel.setText("Preparing..."); + data = new GridData(GridData.HORIZONTAL_ALIGN_BEGINNING); + data.horizontalSpan = colCount; + mBusyLabel.setLayoutData(data); + + // space for the image + mImageLabel = new Label(shell, SWT.BORDER); + data = new GridData(GridData.HORIZONTAL_ALIGN_CENTER); + data.horizontalSpan = colCount; + mImageLabel.setLayoutData(data); + Display display = shell.getDisplay(); + mImageLabel.setImage(ImageLoader.createPlaceHolderArt( + display, 50, 50, display.getSystemColor(SWT.COLOR_BLUE))); + + + shell.setDefaultButton(done); + } + + /** + * Copies the content of {@link #mImageLabel} to the clipboard. + */ + private void copy() { + mClipboard.setContents( + new Object[] { + mImageLabel.getImage().getImageData() + }, new Transfer[] { + ImageTransfer.getInstance() + }); + } + + /** + * Captures a new image from the device, and display it. + */ + private void updateDeviceImage(Shell shell) { + mBusyLabel.setText("Capturing..."); // no effect + + shell.setCursor(shell.getDisplay().getSystemCursor(SWT.CURSOR_WAIT)); + + mRawImage = getDeviceImage(); + + updateImageDisplay(shell); + } + + /** + * Updates the display with {@link #mRawImage}. + * @param shell + */ + private void updateImageDisplay(Shell shell) { + Image image; + if (mRawImage == null) { + Display display = shell.getDisplay(); + image = ImageLoader.createPlaceHolderArt( + display, 320, 240, display.getSystemColor(SWT.COLOR_BLUE)); + + mSave.setEnabled(false); + mBusyLabel.setText("Screen not available"); + } else { + // convert raw data to an Image. + PaletteData palette = new PaletteData( + mRawImage.getRedMask(), + mRawImage.getGreenMask(), + mRawImage.getBlueMask()); + + ImageData imageData = new ImageData(mRawImage.width, mRawImage.height, + mRawImage.bpp, palette, 1, mRawImage.data); + image = new Image(getParent().getDisplay(), imageData); + + mSave.setEnabled(true); + mBusyLabel.setText("Captured image:"); + } + + mImageLabel.setImage(image); + mImageLabel.pack(); + shell.pack(); + + // there's no way to restore old cursor; assume it's ARROW + shell.setCursor(shell.getDisplay().getSystemCursor(SWT.CURSOR_ARROW)); + } + + /** + * Grabs an image from an ADB-connected device and returns it as a {@link RawImage}. + */ + private RawImage getDeviceImage() { + try { + return mDevice.getScreenshot(); + } + catch (IOException ioe) { + Log.w("ddms", "Unable to get frame buffer: " + ioe.getMessage()); + return null; + } catch (TimeoutException e) { + Log.w("ddms", "Unable to get frame buffer: timeout "); + return null; + } catch (AdbCommandRejectedException e) { + Log.w("ddms", "Unable to get frame buffer: " + e.getMessage()); + return null; + } + } + + /* + * Prompt the user to save the image to disk. + */ + private void saveImage(Shell shell) { + FileDialog dlg = new FileDialog(shell, SWT.SAVE); + + Calendar now = Calendar.getInstance(); + String fileName = String.format("device-%tF-%tH%tM%tS.png", + now, now, now, now); + + dlg.setText("Save image..."); + dlg.setFileName(fileName); + + String lastDir = DdmUiPreferences.getStore().getString("lastImageSaveDir"); + if (lastDir.length() == 0) { + lastDir = DdmUiPreferences.getStore().getString("imageSaveDir"); + } + dlg.setFilterPath(lastDir); + dlg.setFilterNames(new String[] { + "PNG Files (*.png)" + }); + dlg.setFilterExtensions(new String[] { + "*.png" //$NON-NLS-1$ + }); + + fileName = dlg.open(); + if (fileName != null) { + // FileDialog.getFilterPath() does NOT always return the current + // directory of the FileDialog; on the Mac it sometimes just returns + // the value the dialog was initialized with. It does however return + // the full path as its return value, so just pick the path from + // there. + if (!fileName.endsWith(".png")) { + fileName = fileName + ".png"; + } + + String saveDir = new File(fileName).getParent(); + if (saveDir != null) { + DdmUiPreferences.getStore().setValue("lastImageSaveDir", saveDir); + } + + Log.d("ddms", "Saving image to " + fileName); + ImageData imageData = mImageLabel.getImage().getImageData(); + + try { + org.eclipse.swt.graphics.ImageLoader loader = + new org.eclipse.swt.graphics.ImageLoader(); + + loader.data = new ImageData[] { imageData }; + loader.save(fileName, SWT.IMAGE_PNG); + } + catch (SWTException e) { + Log.w("ddms", "Unable to save " + fileName + ": " + e.getMessage()); + } + } + } + +} + diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/SelectionDependentPanel.java b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/SelectionDependentPanel.java new file mode 100644 index 00000000..e6d2211c --- /dev/null +++ b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/SelectionDependentPanel.java @@ -0,0 +1,78 @@ +/* + * Copyright (C) 2007 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.ddmuilib; + +import com.android.ddmlib.Client; +import com.android.ddmlib.IDevice; + +/** + * A Panel that requires {@link Device}/{@link Client} selection notifications. + */ +public abstract class SelectionDependentPanel extends Panel { + private IDevice mCurrentDevice = null; + private Client mCurrentClient = null; + + /** + * Returns the current {@link Device}. + * @return the current device or null if none are selected. + */ + protected final IDevice getCurrentDevice() { + return mCurrentDevice; + } + + /** + * Returns the current {@link Client}. + * @return the current client or null if none are selected. + */ + protected final Client getCurrentClient() { + return mCurrentClient; + } + + /** + * Sent when a new device is selected. + * @param selectedDevice the selected device. + */ + public final void deviceSelected(IDevice selectedDevice) { + if (selectedDevice != mCurrentDevice) { + mCurrentDevice = selectedDevice; + deviceSelected(); + } + } + + /** + * Sent when a new client is selected. + * @param selectedClient the selected client. + */ + public final void clientSelected(Client selectedClient) { + if (selectedClient != mCurrentClient) { + mCurrentClient = selectedClient; + clientSelected(); + } + } + + /** + * Sent when a new device is selected. The new device can be accessed + * with {@link #getCurrentDevice()}. + */ + public abstract void deviceSelected(); + + /** + * Sent when a new client is selected. The new client can be accessed + * with {@link #getCurrentClient()}. + */ + public abstract void clientSelected(); +} diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/StackTracePanel.java b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/StackTracePanel.java new file mode 100644 index 00000000..336a5a34 --- /dev/null +++ b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/StackTracePanel.java @@ -0,0 +1,268 @@ +/* + * Copyright (C) 2008 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.ddmuilib; + +import com.android.ddmlib.Client; +import com.android.ddmlib.IStackTraceInfo; + +import org.eclipse.jface.preference.IPreferenceStore; +import org.eclipse.jface.viewers.DoubleClickEvent; +import org.eclipse.jface.viewers.IDoubleClickListener; +import org.eclipse.jface.viewers.ILabelProviderListener; +import org.eclipse.jface.viewers.ISelection; +import org.eclipse.jface.viewers.IStructuredContentProvider; +import org.eclipse.jface.viewers.IStructuredSelection; +import org.eclipse.jface.viewers.ITableLabelProvider; +import org.eclipse.jface.viewers.TableViewer; +import org.eclipse.jface.viewers.Viewer; +import org.eclipse.swt.SWT; +import org.eclipse.swt.graphics.Image; +import org.eclipse.swt.layout.GridLayout; +import org.eclipse.swt.widgets.Composite; +import org.eclipse.swt.widgets.Table; + +/** + * Stack Trace Panel. + *

This is not a panel in the regular sense. Instead this is just an object around the creation + * and management of a Stack Trace display. + *

UI creation is done through + * {@link #createPanel(Composite, String, String, String, String, String, IPreferenceStore)}. + * + */ +public final class StackTracePanel { + + private static ISourceRevealer sSourceRevealer; + + private Table mStackTraceTable; + private TableViewer mStackTraceViewer; + + private Client mCurrentClient; + + + /** + * Content Provider to display the stack trace of a thread. + * Expected input is a {@link IStackTraceInfo} object. + */ + private static class StackTraceContentProvider implements IStructuredContentProvider { + @Override + public Object[] getElements(Object inputElement) { + if (inputElement instanceof IStackTraceInfo) { + // getElement cannot return null, so we return an empty array + // if there's no stack trace + StackTraceElement trace[] = ((IStackTraceInfo)inputElement).getStackTrace(); + if (trace != null) { + return trace; + } + } + + return new Object[0]; + } + + @Override + public void dispose() { + // pass + } + + @Override + public void inputChanged(Viewer viewer, Object oldInput, Object newInput) { + // pass + } + } + + + /** + * A Label Provider to use with {@link StackTraceContentProvider}. It expects the elements to be + * of type {@link StackTraceElement}. + */ + private static class StackTraceLabelProvider implements ITableLabelProvider { + + @Override + public Image getColumnImage(Object element, int columnIndex) { + return null; + } + + @Override + public String getColumnText(Object element, int columnIndex) { + if (element instanceof StackTraceElement) { + StackTraceElement traceElement = (StackTraceElement)element; + switch (columnIndex) { + case 0: + return traceElement.getClassName(); + case 1: + return traceElement.getMethodName(); + case 2: + return traceElement.getFileName(); + case 3: + return Integer.toString(traceElement.getLineNumber()); + case 4: + return Boolean.toString(traceElement.isNativeMethod()); + } + } + + return null; + } + + @Override + public void addListener(ILabelProviderListener listener) { + // pass + } + + @Override + public void dispose() { + // pass + } + + @Override + public boolean isLabelProperty(Object element, String property) { + // pass + return false; + } + + @Override + public void removeListener(ILabelProviderListener listener) { + // pass + } + } + + /** + * Classes which implement this interface provide a method that is able to reveal a method + * in a source editor + */ + public interface ISourceRevealer { + /** + * Sent to reveal a particular line in a source editor + * @param applicationName the name of the application running the source. + * @param className the fully qualified class name + * @param line the line to reveal + */ + public void reveal(String applicationName, String className, int line); + } + + + /** + * Sets the {@link ISourceRevealer} object able to reveal source code in a source editor. + * @param revealer + */ + public static void setSourceRevealer(ISourceRevealer revealer) { + sSourceRevealer = revealer; + } + + /** + * Creates the controls for the StrackTrace display. + *

This method will set the parent {@link Composite} to use a {@link GridLayout} with + * 2 columns. + * @param parent the parent composite. + * @param prefs_stack_col_class + * @param prefs_stack_col_method + * @param prefs_stack_col_file + * @param prefs_stack_col_line + * @param prefs_stack_col_native + * @param store + */ + public Table createPanel(Composite parent, String prefs_stack_col_class, + String prefs_stack_col_method, String prefs_stack_col_file, String prefs_stack_col_line, + String prefs_stack_col_native, IPreferenceStore store) { + + mStackTraceTable = new Table(parent, SWT.MULTI | SWT.FULL_SELECTION); + mStackTraceTable.setHeaderVisible(true); + mStackTraceTable.setLinesVisible(true); + + TableHelper.createTableColumn( + mStackTraceTable, + "Class", + SWT.LEFT, + "SomeLongClassName", //$NON-NLS-1$ + prefs_stack_col_class, store); + + TableHelper.createTableColumn( + mStackTraceTable, + "Method", + SWT.LEFT, + "someLongMethod", //$NON-NLS-1$ + prefs_stack_col_method, store); + + TableHelper.createTableColumn( + mStackTraceTable, + "File", + SWT.LEFT, + "android/somepackage/someotherpackage/somefile.class", //$NON-NLS-1$ + prefs_stack_col_file, store); + + TableHelper.createTableColumn( + mStackTraceTable, + "Line", + SWT.RIGHT, + "99999", //$NON-NLS-1$ + prefs_stack_col_line, store); + + TableHelper.createTableColumn( + mStackTraceTable, + "Native", + SWT.LEFT, + "Native", //$NON-NLS-1$ + prefs_stack_col_native, store); + + mStackTraceViewer = new TableViewer(mStackTraceTable); + mStackTraceViewer.setContentProvider(new StackTraceContentProvider()); + mStackTraceViewer.setLabelProvider(new StackTraceLabelProvider()); + + mStackTraceViewer.addDoubleClickListener(new IDoubleClickListener() { + @Override + public void doubleClick(DoubleClickEvent event) { + if (sSourceRevealer != null && mCurrentClient != null) { + // get the selected stack trace element + ISelection selection = mStackTraceViewer.getSelection(); + + if (selection instanceof IStructuredSelection) { + IStructuredSelection structuredSelection = (IStructuredSelection)selection; + Object object = structuredSelection.getFirstElement(); + if (object instanceof StackTraceElement) { + StackTraceElement traceElement = (StackTraceElement)object; + + if (traceElement.isNativeMethod() == false) { + sSourceRevealer.reveal( + mCurrentClient.getClientData().getClientDescription(), + traceElement.getClassName(), + traceElement.getLineNumber()); + } + } + } + } + } + }); + + return mStackTraceTable; + } + + /** + * Sets the input for the {@link TableViewer}. + * @param input the {@link IStackTraceInfo} that will provide the viewer with the list of + * {@link StackTraceElement} + */ + public void setViewerInput(IStackTraceInfo input) { + mStackTraceViewer.setInput(input); + mStackTraceViewer.refresh(); + } + + /** + * Sets the current client running the stack trace. + * @param currentClient the {@link Client}. + */ + public void setCurrentClient(Client currentClient) { + mCurrentClient = currentClient; + } +} diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/SyncProgressHelper.java b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/SyncProgressHelper.java new file mode 100644 index 00000000..732de591 --- /dev/null +++ b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/SyncProgressHelper.java @@ -0,0 +1,100 @@ +/* + * 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.ddmuilib; + +import com.android.ddmlib.SyncException; +import com.android.ddmlib.SyncService; +import com.android.ddmlib.SyncService.ISyncProgressMonitor; +import com.android.ddmlib.TimeoutException; + +import org.eclipse.core.runtime.IProgressMonitor; +import org.eclipse.jface.dialogs.ProgressMonitorDialog; +import org.eclipse.jface.operation.IRunnableWithProgress; +import org.eclipse.swt.widgets.Shell; + +import java.io.IOException; +import java.lang.reflect.InvocationTargetException; + +/** + * Helper class to run a Sync in a {@link ProgressMonitorDialog}. + */ +public class SyncProgressHelper { + + /** + * a runnable class run with an {@link ISyncProgressMonitor}. + */ + public interface SyncRunnable { + /** Runs the sync action */ + void run(ISyncProgressMonitor monitor) throws SyncException, IOException, TimeoutException; + /** close the {@link SyncService} */ + void close(); + } + + /** + * Runs a {@link SyncRunnable} in a {@link ProgressMonitorDialog}. + * @param runnable The {@link SyncRunnable} to run. + * @param progressMessage the message to display in the progress dialog + * @param parentShell the parent shell for the progress dialog. + * + * @throws InvocationTargetException + * @throws InterruptedException + * @throws SyncException if an error happens during the push of the package on the device. + * @throws IOException + * @throws TimeoutException + */ + public static void run(final SyncRunnable runnable, final String progressMessage, + final Shell parentShell) + throws InvocationTargetException, InterruptedException, SyncException, IOException, + TimeoutException { + + final Exception[] result = new Exception[1]; + new ProgressMonitorDialog(parentShell).run(true, true, new IRunnableWithProgress() { + @Override + public void run(IProgressMonitor monitor) { + try { + runnable.run(new SyncProgressMonitor(monitor, progressMessage)); + } catch (Exception e) { + result[0] = e; + } finally { + runnable.close(); + } + } + }); + + if (result[0] instanceof SyncException) { + SyncException se = (SyncException)result[0]; + if (se.wasCanceled()) { + // no need to throw this + return; + } + throw se; + } + + // just do some casting so that the method declaration matches what's thrown. + if (result[0] instanceof TimeoutException) { + throw (TimeoutException)result[0]; + } + + if (result[0] instanceof IOException) { + throw (IOException)result[0]; + } + + if (result[0] instanceof RuntimeException) { + throw (RuntimeException)result[0]; + } + } +} diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/SyncProgressMonitor.java b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/SyncProgressMonitor.java new file mode 100644 index 00000000..4254f67a --- /dev/null +++ b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/SyncProgressMonitor.java @@ -0,0 +1,60 @@ +/* + * Copyright (C) 2009 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.ddmuilib; + +import com.android.ddmlib.SyncService.ISyncProgressMonitor; + +import org.eclipse.core.runtime.IProgressMonitor; + +/** + * Implementation of the {@link ISyncProgressMonitor} wrapping an Eclipse {@link IProgressMonitor}. + */ +public class SyncProgressMonitor implements ISyncProgressMonitor { + + private IProgressMonitor mMonitor; + private String mName; + + public SyncProgressMonitor(IProgressMonitor monitor, String name) { + mMonitor = monitor; + mName = name; + } + + @Override + public void start(int totalWork) { + mMonitor.beginTask(mName, totalWork); + } + + @Override + public void stop() { + mMonitor.done(); + } + + @Override + public void advance(int work) { + mMonitor.worked(work); + } + + @Override + public boolean isCanceled() { + return mMonitor.isCanceled(); + } + + @Override + public void startSubTask(String name) { + mMonitor.subTask(name); + } +} diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/SysinfoPanel.java b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/SysinfoPanel.java new file mode 100644 index 00000000..3ca5ff3b --- /dev/null +++ b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/SysinfoPanel.java @@ -0,0 +1,595 @@ +/* + * Copyright (C) 2008 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.ddmuilib; + +import com.android.ddmlib.AdbCommandRejectedException; +import com.android.ddmlib.Client; +import com.android.ddmlib.IShellOutputReceiver; +import com.android.ddmlib.Log; +import com.android.ddmlib.ShellCommandUnresponsiveException; +import com.android.ddmlib.TimeoutException; + +import org.eclipse.swt.SWT; +import org.eclipse.swt.events.SelectionAdapter; +import org.eclipse.swt.events.SelectionEvent; +import org.eclipse.swt.layout.GridData; +import org.eclipse.swt.layout.GridLayout; +import org.eclipse.swt.layout.RowLayout; +import org.eclipse.swt.widgets.Button; +import org.eclipse.swt.widgets.Combo; +import org.eclipse.swt.widgets.Composite; +import org.eclipse.swt.widgets.Control; +import org.eclipse.swt.widgets.FileDialog; +import org.eclipse.swt.widgets.Label; +import org.jfree.chart.ChartFactory; +import org.jfree.chart.JFreeChart; +import org.jfree.data.general.DefaultPieDataset; +import org.jfree.experimental.chart.swt.ChartComposite; + +import java.io.BufferedReader; +import java.io.File; +import java.io.FileOutputStream; +import java.io.FileReader; +import java.io.IOException; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Displays system information graphs obtained from a bugreport file or device. + */ +public class SysinfoPanel extends TablePanel implements IShellOutputReceiver { + + // UI components + private Label mLabel; + private Button mFetchButton; + private Combo mDisplayMode; + + private DefaultPieDataset mDataset; + + // The bugreport file to process + private File mDataFile; + + // To get output from adb commands + private FileOutputStream mTempStream; + + // Selects the current display: MODE_CPU, etc. + private int mMode = 0; + + private static final int MODE_CPU = 0; + private static final int MODE_ALARM = 1; + private static final int MODE_WAKELOCK = 2; + private static final int MODE_MEMINFO = 3; + private static final int MODE_SYNC = 4; + + // argument to dumpsys; section in the bugreport holding the data + private static final String BUGREPORT_SECTION[] = {"cpuinfo", "alarm", + "batteryinfo", "MEMORY INFO", "content"}; + + private static final String DUMP_COMMAND[] = {"dumpsys cpuinfo", + "dumpsys alarm", "dumpsys batteryinfo", "cat /proc/meminfo ; procrank", + "dumpsys content"}; + + private static final String CAPTIONS[] = {"CPU load", "Alarms", + "Wakelocks", "Memory usage", "Sync"}; + + /** + * Generates the dataset to display. + * + * @param file The bugreport file to process. + */ + public void generateDataset(File file) { + mDataset.clear(); + mLabel.setText(""); + if (file == null) { + return; + } + try { + BufferedReader br = getBugreportReader(file); + if (mMode == MODE_CPU) { + readCpuDataset(br); + } else if (mMode == MODE_ALARM) { + readAlarmDataset(br); + } else if (mMode == MODE_WAKELOCK) { + readWakelockDataset(br); + } else if (mMode == MODE_MEMINFO) { + readMeminfoDataset(br); + } else if (mMode == MODE_SYNC) { + readSyncDataset(br); + } + } catch (IOException e) { + Log.e("DDMS", e); + } + } + + /** + * Sent when a new device is selected. The new device can be accessed with + * {@link #getCurrentDevice()} + */ + @Override + public void deviceSelected() { + if (getCurrentDevice() != null) { + mFetchButton.setEnabled(true); + loadFromDevice(); + } else { + mFetchButton.setEnabled(false); + } + } + + /** + * Sent when a new client is selected. The new client can be accessed with + * {@link #getCurrentClient()}. + */ + @Override + public void clientSelected() { + } + + /** + * Sets the focus to the proper control inside the panel. + */ + @Override + public void setFocus() { + mDisplayMode.setFocus(); + } + + /** + * Fetches a new bugreport from the device and updates the display. + * Fetching is asynchronous. See also addOutput, flush, and isCancelled. + */ + private void loadFromDevice() { + try { + initShellOutputBuffer(); + if (mMode == MODE_MEMINFO) { + // Hack to add bugreport-style section header for meminfo + mTempStream.write("------ MEMORY INFO ------\n".getBytes()); + } + getCurrentDevice().executeShellCommand( + DUMP_COMMAND[mMode], this); + } catch (IOException e) { + Log.e("DDMS", e); + } catch (TimeoutException e) { + Log.e("DDMS", e); + } catch (AdbCommandRejectedException e) { + Log.e("DDMS", e); + } catch (ShellCommandUnresponsiveException e) { + Log.e("DDMS", e); + } + } + + /** + * Initializes temporary output file for executeShellCommand(). + * + * @throws IOException on file error + */ + void initShellOutputBuffer() throws IOException { + mDataFile = File.createTempFile("ddmsfile", ".txt"); + mDataFile.deleteOnExit(); + mTempStream = new FileOutputStream(mDataFile); + } + + /** + * Adds output to the temp file. IShellOutputReceiver method. Called by + * executeShellCommand(). + */ + @Override + public void addOutput(byte[] data, int offset, int length) { + try { + mTempStream.write(data, offset, length); + } + catch (IOException e) { + Log.e("DDMS", e); + } + } + + /** + * Processes output from shell command. IShellOutputReceiver method. The + * output is passed to generateDataset(). Called by executeShellCommand() on + * completion. + */ + @Override + public void flush() { + if (mTempStream != null) { + try { + mTempStream.close(); + generateDataset(mDataFile); + mTempStream = null; + mDataFile = null; + } catch (IOException e) { + Log.e("DDMS", e); + } + } + } + + /** + * IShellOutputReceiver method. + * + * @return false - don't cancel + */ + @Override + public boolean isCancelled() { + return false; + } + + /** + * Create our controls for the UI panel. + */ + @Override + protected Control createControl(Composite parent) { + Composite top = new Composite(parent, SWT.NONE); + top.setLayout(new GridLayout(1, false)); + top.setLayoutData(new GridData(GridData.FILL_BOTH)); + + Composite buttons = new Composite(top, SWT.NONE); + buttons.setLayout(new RowLayout()); + + mDisplayMode = new Combo(buttons, SWT.PUSH); + for (String mode : CAPTIONS) { + mDisplayMode.add(mode); + } + mDisplayMode.select(mMode); + mDisplayMode.addSelectionListener(new SelectionAdapter() { + @Override + public void widgetSelected(SelectionEvent e) { + mMode = mDisplayMode.getSelectionIndex(); + if (mDataFile != null) { + generateDataset(mDataFile); + } else if (getCurrentDevice() != null) { + loadFromDevice(); + } + } + }); + + final Button loadButton = new Button(buttons, SWT.PUSH); + loadButton.setText("Load from File"); + loadButton.addSelectionListener(new SelectionAdapter() { + @Override + public void widgetSelected(SelectionEvent e) { + FileDialog fileDialog = new FileDialog(loadButton.getShell(), + SWT.OPEN); + fileDialog.setText("Load bugreport"); + String filename = fileDialog.open(); + if (filename != null) { + mDataFile = new File(filename); + generateDataset(mDataFile); + } + } + }); + + mFetchButton = new Button(buttons, SWT.PUSH); + mFetchButton.setText("Update from Device"); + mFetchButton.setEnabled(false); + mFetchButton.addSelectionListener(new SelectionAdapter() { + @Override + public void widgetSelected(SelectionEvent e) { + loadFromDevice(); + } + }); + + mLabel = new Label(top, SWT.NONE); + mLabel.setLayoutData(new GridData(GridData.FILL_HORIZONTAL)); + + mDataset = new DefaultPieDataset(); + JFreeChart chart = ChartFactory.createPieChart("", mDataset, false + /* legend */, true/* tooltips */, false /* urls */); + + ChartComposite chartComposite = new ChartComposite(top, + SWT.BORDER, chart, + ChartComposite.DEFAULT_HEIGHT, + ChartComposite.DEFAULT_HEIGHT, + ChartComposite.DEFAULT_MINIMUM_DRAW_WIDTH, + ChartComposite.DEFAULT_MINIMUM_DRAW_HEIGHT, + 3000, + // max draw width. We don't want it to zoom, so we put a big number + 3000, + // max draw height. We don't want it to zoom, so we put a big number + true, // off-screen buffer + true, // properties + true, // save + true, // print + false, // zoom + true); + chartComposite.setLayoutData(new GridData(GridData.FILL_BOTH)); + return top; + } + + @Override + public void clientChanged(final Client client, int changeMask) { + // Don't care + } + + /** + * Helper to open a bugreport and skip to the specified section. + * + * @param file File to open + * @return Reader to bugreport file + * @throws java.io.IOException on file error + */ + private BufferedReader getBugreportReader(File file) throws + IOException { + BufferedReader br = new BufferedReader(new FileReader(file)); + // Skip over the unwanted bugreport sections + while (true) { + String line = br.readLine(); + if (line == null) { + Log.d("DDMS", "Service not found " + line); + break; + } + if ((line.startsWith("DUMP OF SERVICE ") || line.startsWith("-----")) && + line.indexOf(BUGREPORT_SECTION[mMode]) > 0) { + break; + } + } + return br; + } + + /** + * Parse the time string generated by BatteryStats. + * A typical new-format string is "11d 13h 45m 39s 999ms". + * A typical old-format string is "12.3 sec". + * @return time in ms + */ + private static long parseTimeMs(String s) { + long total = 0; + // Matches a single component e.g. "12.3 sec" or "45ms" + Pattern p = Pattern.compile("([\\d\\.]+)\\s*([a-z]+)"); + Matcher m = p.matcher(s); + while (m.find()) { + String label = m.group(2); + if ("sec".equals(label)) { + // Backwards compatibility with old time format + total += (long) (Double.parseDouble(m.group(1)) * 1000); + continue; + } + long value = Integer.parseInt(m.group(1)); + if ("d".equals(label)) { + total += value * 24 * 60 * 60 * 1000; + } else if ("h".equals(label)) { + total += value * 60 * 60 * 1000; + } else if ("m".equals(label)) { + total += value * 60 * 1000; + } else if ("s".equals(label)) { + total += value * 1000; + } else if ("ms".equals(label)) { + total += value; + } + } + return total; + } + /** + * Processes wakelock information from bugreport. Updates mDataset with the + * new data. + * + * @param br Reader providing the content + * @throws IOException if error reading file + */ + void readWakelockDataset(BufferedReader br) throws IOException { + Pattern lockPattern = Pattern.compile("Wake lock (\\S+): (.+) partial"); + Pattern totalPattern = Pattern.compile("Total: (.+) uptime"); + double total = 0; + boolean inCurrent = false; + + while (true) { + String line = br.readLine(); + if (line == null || line.startsWith("DUMP OF SERVICE")) { + // Done, or moved on to the next service + break; + } + if (line.startsWith("Current Battery Usage Statistics")) { + inCurrent = true; + } else if (inCurrent) { + Matcher m = lockPattern.matcher(line); + if (m.find()) { + double value = parseTimeMs(m.group(2)) / 1000.; + mDataset.setValue(m.group(1), value); + total -= value; + } else { + m = totalPattern.matcher(line); + if (m.find()) { + total += parseTimeMs(m.group(1)) / 1000.; + } + } + } + } + if (total > 0) { + mDataset.setValue("Unlocked", total); + } + } + + /** + * Processes alarm information from bugreport. Updates mDataset with the new + * data. + * + * @param br Reader providing the content + * @throws IOException if error reading file + */ + void readAlarmDataset(BufferedReader br) throws IOException { + Pattern pattern = Pattern + .compile("(\\d+) alarms: Intent .*\\.([^. ]+) flags"); + + while (true) { + String line = br.readLine(); + if (line == null || line.startsWith("DUMP OF SERVICE")) { + // Done, or moved on to the next service + break; + } + Matcher m = pattern.matcher(line); + if (m.find()) { + long count = Long.parseLong(m.group(1)); + String name = m.group(2); + mDataset.setValue(name, count); + } + } + } + + /** + * Processes cpu load information from bugreport. Updates mDataset with the + * new data. + * + * @param br Reader providing the content + * @throws IOException if error reading file + */ + void readCpuDataset(BufferedReader br) throws IOException { + Pattern pattern = Pattern + .compile("(\\S+): (\\S+)% = (.+)% user . (.+)% kernel"); + + while (true) { + String line = br.readLine(); + if (line == null || line.startsWith("DUMP OF SERVICE")) { + // Done, or moved on to the next service + break; + } + if (line.startsWith("Load:")) { + mLabel.setText(line); + continue; + } + Matcher m = pattern.matcher(line); + if (m.find()) { + String name = m.group(1); + long both = Long.parseLong(m.group(2)); + long user = Long.parseLong(m.group(3)); + long kernel = Long.parseLong(m.group(4)); + if ("TOTAL".equals(name)) { + if (both < 100) { + mDataset.setValue("Idle", (100 - both)); + } + } else { + // Try to make graphs more useful even with rounding; + // log often has 0% user + 0% kernel = 1% total + // We arbitrarily give extra to kernel + if (user > 0) { + mDataset.setValue(name + " (user)", user); + } + if (kernel > 0) { + mDataset.setValue(name + " (kernel)" , both - user); + } + if (user == 0 && kernel == 0 && both > 0) { + mDataset.setValue(name, both); + } + } + } + } + } + + /** + * Processes meminfo information from bugreport. Updates mDataset with the + * new data. + * + * @param br Reader providing the content + * @throws IOException if error reading file + */ + void readMeminfoDataset(BufferedReader br) throws IOException { + Pattern valuePattern = Pattern.compile("(\\d+) kB"); + long total = 0; + long other = 0; + mLabel.setText("PSS in kB"); + + // Scan meminfo + while (true) { + String line = br.readLine(); + if (line == null) { + // End of file + break; + } + Matcher m = valuePattern.matcher(line); + if (m.find()) { + long kb = Long.parseLong(m.group(1)); + if (line.startsWith("MemTotal")) { + total = kb; + } else if (line.startsWith("MemFree")) { + mDataset.setValue("Free", kb); + total -= kb; + } else if (line.startsWith("Slab")) { + mDataset.setValue("Slab", kb); + total -= kb; + } else if (line.startsWith("PageTables")) { + mDataset.setValue("PageTables", kb); + total -= kb; + } else if (line.startsWith("Buffers") && kb > 0) { + mDataset.setValue("Buffers", kb); + total -= kb; + } else if (line.startsWith("Inactive")) { + mDataset.setValue("Inactive", kb); + total -= kb; + } else if (line.startsWith("MemFree")) { + mDataset.setValue("Free", kb); + total -= kb; + } + } else { + break; + } + } + // Scan procrank + while (true) { + String line = br.readLine(); + if (line == null) { + break; + } + if (line.indexOf("PROCRANK") >= 0 || line.indexOf("PID") >= 0) { + // procrank header + continue; + } + if (line.indexOf("----") >= 0) { + //end of procrank section + break; + } + // Extract pss field from procrank output + long pss = Long.parseLong(line.substring(23, 31).trim()); + String cmdline = line.substring(43).trim().replace("/system/bin/", ""); + // Arbitrary minimum size to display + if (pss > 2000) { + mDataset.setValue(cmdline, pss); + } else { + other += pss; + } + total -= pss; + } + mDataset.setValue("Other", other); + mDataset.setValue("Unknown", total); + } + + /** + * Processes sync information from bugreport. Updates mDataset with the new + * data. + * + * @param br Reader providing the content + * @throws IOException if error reading file + */ + void readSyncDataset(BufferedReader br) throws IOException { + while (true) { + String line = br.readLine(); + if (line == null || line.startsWith("DUMP OF SERVICE")) { + // Done, or moved on to the next service + break; + } + if (line.startsWith(" |") && line.length() > 70) { + String authority = line.substring(3, 18).trim(); + String duration = line.substring(61, 70).trim(); + // Duration is MM:SS or HH:MM:SS (DateUtils.formatElapsedTime) + String durParts[] = duration.split(":"); + if (durParts.length == 2) { + long dur = Long.parseLong(durParts[0]) * 60 + Long + .parseLong(durParts[1]); + mDataset.setValue(authority, dur); + } else if (duration.length() == 3) { + long dur = Long.parseLong(durParts[0]) * 3600 + + Long.parseLong(durParts[1]) * 60 + Long + .parseLong(durParts[2]); + mDataset.setValue(authority, dur); + } + } + } + } +} diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/TableHelper.java b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/TableHelper.java new file mode 100644 index 00000000..66dcc0a4 --- /dev/null +++ b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/TableHelper.java @@ -0,0 +1,209 @@ +/* + * Copyright (C) 2007 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.ddmuilib; + +import org.eclipse.jface.preference.IPreferenceStore; +import org.eclipse.swt.events.ControlEvent; +import org.eclipse.swt.events.ControlListener; +import org.eclipse.swt.widgets.Table; +import org.eclipse.swt.widgets.TableColumn; +import org.eclipse.swt.widgets.Tree; +import org.eclipse.swt.widgets.TreeColumn; + +/** + * Utility class to help using Table objects. + * + */ +public final class TableHelper { + /** + * Create a TableColumn with the specified parameters. If a + * PreferenceStore object and a preference entry name String + * object are provided then the column will listen to change in its width + * and update the preference store accordingly. + * + * @param parent The Table parent object + * @param header The header string + * @param style The column style + * @param sample_text A sample text to figure out column width if preference + * value is missing + * @param pref_name The preference entry name for column width + * @param prefs The preference store + * @return The TableColumn object that was created + */ + public static TableColumn createTableColumn(Table parent, String header, + int style, String sample_text, final String pref_name, + final IPreferenceStore prefs) { + + // create the column + TableColumn col = new TableColumn(parent, style); + + // if there is no pref store or the entry is missing, we use the sample + // text and pack the column. + // Otherwise we just read the width from the prefs and apply it. + if (prefs == null || prefs.contains(pref_name) == false) { + col.setText(sample_text); + col.pack(); + + // init the prefs store with the current value + if (prefs != null) { + prefs.setValue(pref_name, col.getWidth()); + } + } else { + col.setWidth(prefs.getInt(pref_name)); + } + + // set the header + col.setText(header); + + // if there is a pref store and a pref entry name, then we setup a + // listener to catch column resize to put store the new width value. + if (prefs != null && pref_name != null) { + col.addControlListener(new ControlListener() { + @Override + public void controlMoved(ControlEvent e) { + } + + @Override + public void controlResized(ControlEvent e) { + // get the new width + int w = ((TableColumn)e.widget).getWidth(); + + // store in pref store + prefs.setValue(pref_name, w); + } + }); + } + + return col; + } + + /** + * Create a TreeColumn with the specified parameters. If a + * PreferenceStore object and a preference entry name String + * object are provided then the column will listen to change in its width + * and update the preference store accordingly. + * + * @param parent The Table parent object + * @param header The header string + * @param style The column style + * @param sample_text A sample text to figure out column width if preference + * value is missing + * @param pref_name The preference entry name for column width + * @param prefs The preference store + */ + public static void createTreeColumn(Tree parent, String header, int style, + String sample_text, final String pref_name, + final IPreferenceStore prefs) { + + // create the column + TreeColumn col = new TreeColumn(parent, style); + + // if there is no pref store or the entry is missing, we use the sample + // text and pack the column. + // Otherwise we just read the width from the prefs and apply it. + if (prefs == null || prefs.contains(pref_name) == false) { + col.setText(sample_text); + col.pack(); + + // init the prefs store with the current value + if (prefs != null) { + prefs.setValue(pref_name, col.getWidth()); + } + } else { + col.setWidth(prefs.getInt(pref_name)); + } + + // set the header + col.setText(header); + + // if there is a pref store and a pref entry name, then we setup a + // listener to catch column resize to put store the new width value. + if (prefs != null && pref_name != null) { + col.addControlListener(new ControlListener() { + @Override + public void controlMoved(ControlEvent e) { + } + + @Override + public void controlResized(ControlEvent e) { + // get the new width + int w = ((TreeColumn)e.widget).getWidth(); + + // store in pref store + prefs.setValue(pref_name, w); + } + }); + } + } + + /** + * Create a TreeColumn with the specified parameters. If a + * PreferenceStore object and a preference entry name String + * object are provided then the column will listen to change in its width + * and update the preference store accordingly. + * + * @param parent The Table parent object + * @param header The header string + * @param style The column style + * @param width the width of the column if the preference value is missing + * @param pref_name The preference entry name for column width + * @param prefs The preference store + */ + public static void createTreeColumn(Tree parent, String header, int style, + int width, final String pref_name, + final IPreferenceStore prefs) { + + // create the column + TreeColumn col = new TreeColumn(parent, style); + + // if there is no pref store or the entry is missing, we use the sample + // text and pack the column. + // Otherwise we just read the width from the prefs and apply it. + if (prefs == null || prefs.contains(pref_name) == false) { + col.setWidth(width); + + // init the prefs store with the current value + if (prefs != null) { + prefs.setValue(pref_name, width); + } + } else { + col.setWidth(prefs.getInt(pref_name)); + } + + // set the header + col.setText(header); + + // if there is a pref store and a pref entry name, then we setup a + // listener to catch column resize to put store the new width value. + if (prefs != null && pref_name != null) { + col.addControlListener(new ControlListener() { + @Override + public void controlMoved(ControlEvent e) { + } + + @Override + public void controlResized(ControlEvent e) { + // get the new width + int w = ((TreeColumn)e.widget).getWidth(); + + // store in pref store + prefs.setValue(pref_name, w); + } + }); + } + } +} diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/TablePanel.java b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/TablePanel.java new file mode 100644 index 00000000..c1eb7f6a --- /dev/null +++ b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/TablePanel.java @@ -0,0 +1,132 @@ +/* + * Copyright (C) 2007 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.ddmuilib; + +import com.android.ddmuilib.ITableFocusListener.IFocusedTableActivator; + +import org.eclipse.swt.dnd.Clipboard; +import org.eclipse.swt.dnd.TextTransfer; +import org.eclipse.swt.dnd.Transfer; +import org.eclipse.swt.events.FocusEvent; +import org.eclipse.swt.events.FocusListener; +import org.eclipse.swt.widgets.Table; +import org.eclipse.swt.widgets.TableItem; + +import java.util.Arrays; + +/** + * Base class for panel containing Table that need to support copy-paste-selectAll + */ +public abstract class TablePanel extends ClientDisplayPanel { + private ITableFocusListener mGlobalListener; + + /** + * Sets a TableFocusListener which will be notified when one of the tables + * gets or loses focus. + * + * @param listener + */ + public void setTableFocusListener(ITableFocusListener listener) { + // record the global listener, to make sure table created after + // this call will still be setup. + mGlobalListener = listener; + + setTableFocusListener(); + } + + /** + * Sets up the Table of object of the panel to work with the global listener.
+ * Default implementation does nothing. + */ + protected void setTableFocusListener() { + + } + + /** + * Sets up a Table object to notify the global Table Focus listener when it + * gets or loses the focus. + * + * @param table the Table object. + * @param colStart + * @param colEnd + */ + protected final void addTableToFocusListener(final Table table, + final int colStart, final int colEnd) { + // create the activator for this table + final IFocusedTableActivator activator = new IFocusedTableActivator() { + @Override + public void copy(Clipboard clipboard) { + int[] selection = table.getSelectionIndices(); + + // we need to sort the items to be sure. + Arrays.sort(selection); + + // all lines must be concatenated. + StringBuilder sb = new StringBuilder(); + + // loop on the selection and output the file. + for (int i : selection) { + TableItem item = table.getItem(i); + for (int c = colStart ; c <= colEnd ; c++) { + sb.append(item.getText(c)); + sb.append('\t'); + } + sb.append('\n'); + } + + // now add that to the clipboard if the string has content + String data = sb.toString(); + if (data != null && data.length() > 0) { + clipboard.setContents( + new Object[] { data }, + new Transfer[] { TextTransfer.getInstance() }); + } + } + + @Override + public void selectAll() { + table.selectAll(); + } + }; + + // add the focus listener on the table to notify the global listener + table.addFocusListener(new FocusListener() { + @Override + public void focusGained(FocusEvent e) { + mGlobalListener.focusGained(activator); + } + + @Override + public void focusLost(FocusEvent e) { + mGlobalListener.focusLost(activator); + } + }); + } + + /** + * Sets up a Table object to notify the global Table Focus listener when it + * gets or loses the focus.
+ * When the copy method is invoked, all columns are put in the clipboard, separated + * by tabs + * + * @param table the Table object. + */ + protected final void addTableToFocusListener(final Table table) { + addTableToFocusListener(table, 0, table.getColumnCount()-1); + } + +} diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/ThreadPanel.java b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/ThreadPanel.java new file mode 100644 index 00000000..f88b4c4a --- /dev/null +++ b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/ThreadPanel.java @@ -0,0 +1,589 @@ +/* + * Copyright (C) 2007 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.ddmuilib; + +import com.android.ddmlib.AndroidDebugBridge.IClientChangeListener; +import com.android.ddmlib.Client; +import com.android.ddmlib.ThreadInfo; + +import org.eclipse.jface.preference.IPreferenceStore; +import org.eclipse.jface.viewers.DoubleClickEvent; +import org.eclipse.jface.viewers.IDoubleClickListener; +import org.eclipse.jface.viewers.ILabelProviderListener; +import org.eclipse.jface.viewers.ISelection; +import org.eclipse.jface.viewers.ISelectionChangedListener; +import org.eclipse.jface.viewers.IStructuredContentProvider; +import org.eclipse.jface.viewers.IStructuredSelection; +import org.eclipse.jface.viewers.ITableLabelProvider; +import org.eclipse.jface.viewers.SelectionChangedEvent; +import org.eclipse.jface.viewers.TableViewer; +import org.eclipse.jface.viewers.Viewer; +import org.eclipse.swt.SWT; +import org.eclipse.swt.SWTException; +import org.eclipse.swt.custom.StackLayout; +import org.eclipse.swt.events.SelectionAdapter; +import org.eclipse.swt.events.SelectionEvent; +import org.eclipse.swt.graphics.Color; +import org.eclipse.swt.graphics.Image; +import org.eclipse.swt.graphics.Rectangle; +import org.eclipse.swt.layout.FormAttachment; +import org.eclipse.swt.layout.FormData; +import org.eclipse.swt.layout.FormLayout; +import org.eclipse.swt.layout.GridData; +import org.eclipse.swt.layout.GridLayout; +import org.eclipse.swt.widgets.Button; +import org.eclipse.swt.widgets.Composite; +import org.eclipse.swt.widgets.Control; +import org.eclipse.swt.widgets.Display; +import org.eclipse.swt.widgets.Event; +import org.eclipse.swt.widgets.Label; +import org.eclipse.swt.widgets.Listener; +import org.eclipse.swt.widgets.Sash; +import org.eclipse.swt.widgets.Table; + +import java.util.Date; + +/** + * Base class for our information panels. + */ +public class ThreadPanel extends TablePanel { + + private final static String PREFS_THREAD_COL_ID = "threadPanel.Col0"; //$NON-NLS-1$ + private final static String PREFS_THREAD_COL_TID = "threadPanel.Col1"; //$NON-NLS-1$ + private final static String PREFS_THREAD_COL_STATUS = "threadPanel.Col2"; //$NON-NLS-1$ + private final static String PREFS_THREAD_COL_UTIME = "threadPanel.Col3"; //$NON-NLS-1$ + private final static String PREFS_THREAD_COL_STIME = "threadPanel.Col4"; //$NON-NLS-1$ + private final static String PREFS_THREAD_COL_NAME = "threadPanel.Col5"; //$NON-NLS-1$ + + private final static String PREFS_THREAD_SASH = "threadPanel.sash"; //$NON-NLS-1$ + + private static final String PREFS_STACK_COL_CLASS = "threadPanel.stack.col0"; //$NON-NLS-1$ + private static final String PREFS_STACK_COL_METHOD = "threadPanel.stack.col1"; //$NON-NLS-1$ + private static final String PREFS_STACK_COL_FILE = "threadPanel.stack.col2"; //$NON-NLS-1$ + private static final String PREFS_STACK_COL_LINE = "threadPanel.stack.col3"; //$NON-NLS-1$ + private static final String PREFS_STACK_COL_NATIVE = "threadPanel.stack.col4"; //$NON-NLS-1$ + + private Display mDisplay; + private Composite mBase; + private Label mNotEnabled; + private Label mNotSelected; + + private Composite mThreadBase; + private Table mThreadTable; + private TableViewer mThreadViewer; + + private Composite mStackTraceBase; + private Button mRefreshStackTraceButton; + private Label mStackTraceTimeLabel; + private StackTracePanel mStackTracePanel; + private Table mStackTraceTable; + + /** Indicates if a timer-based Runnable is current requesting thread updates regularly. */ + private boolean mMustStopRecurringThreadUpdate = false; + /** Flag to tell the recurring thread update to stop running */ + private boolean mRecurringThreadUpdateRunning = false; + + private Object mLock = new Object(); + + private static final String[] THREAD_STATUS = { + "zombie", "running", "timed-wait", "monitor", + "wait", "init", "start", "native", "vmwait", + "suspended" + }; + + /** + * Content Provider to display the threads of a client. + * Expected input is a {@link Client} object. + */ + private static class ThreadContentProvider implements IStructuredContentProvider { + @Override + public Object[] getElements(Object inputElement) { + if (inputElement instanceof Client) { + return ((Client)inputElement).getClientData().getThreads(); + } + + return new Object[0]; + } + + @Override + public void dispose() { + // pass + } + + @Override + public void inputChanged(Viewer viewer, Object oldInput, Object newInput) { + // pass + } + } + + + /** + * A Label Provider to use with {@link ThreadContentProvider}. It expects the elements to be + * of type {@link ThreadInfo}. + */ + private static class ThreadLabelProvider implements ITableLabelProvider { + + @Override + public Image getColumnImage(Object element, int columnIndex) { + return null; + } + + @Override + public String getColumnText(Object element, int columnIndex) { + if (element instanceof ThreadInfo) { + ThreadInfo thread = (ThreadInfo)element; + switch (columnIndex) { + case 0: + return (thread.isDaemon() ? "*" : "") + //$NON-NLS-1$ //$NON-NLS-2$ + String.valueOf(thread.getThreadId()); + case 1: + return String.valueOf(thread.getTid()); + case 2: + if (thread.getStatus() >= 0 && thread.getStatus() < THREAD_STATUS.length) + return THREAD_STATUS[thread.getStatus()]; + return "unknown"; + case 3: + return String.valueOf(thread.getUtime()); + case 4: + return String.valueOf(thread.getStime()); + case 5: + return thread.getThreadName(); + } + } + + return null; + } + + @Override + public void addListener(ILabelProviderListener listener) { + // pass + } + + @Override + public void dispose() { + // pass + } + + @Override + public boolean isLabelProperty(Object element, String property) { + // pass + return false; + } + + @Override + public void removeListener(ILabelProviderListener listener) { + // pass + } + } + + /** + * Create our control(s). + */ + @Override + protected Control createControl(Composite parent) { + mDisplay = parent.getDisplay(); + + final IPreferenceStore store = DdmUiPreferences.getStore(); + + mBase = new Composite(parent, SWT.NONE); + mBase.setLayout(new StackLayout()); + + // UI for thread not enabled + mNotEnabled = new Label(mBase, SWT.CENTER | SWT.WRAP); + mNotEnabled.setText("Thread updates not enabled for selected client\n" + + "(use toolbar button to enable)"); + + // UI for not client selected + mNotSelected = new Label(mBase, SWT.CENTER | SWT.WRAP); + mNotSelected.setText("no client is selected"); + + // base composite for selected client with enabled thread update. + mThreadBase = new Composite(mBase, SWT.NONE); + mThreadBase.setLayout(new FormLayout()); + + // table above the sash + mThreadTable = new Table(mThreadBase, SWT.MULTI | SWT.FULL_SELECTION); + mThreadTable.setHeaderVisible(true); + mThreadTable.setLinesVisible(true); + + TableHelper.createTableColumn( + mThreadTable, + "ID", + SWT.RIGHT, + "888", //$NON-NLS-1$ + PREFS_THREAD_COL_ID, store); + + TableHelper.createTableColumn( + mThreadTable, + "Tid", + SWT.RIGHT, + "88888", //$NON-NLS-1$ + PREFS_THREAD_COL_TID, store); + + TableHelper.createTableColumn( + mThreadTable, + "Status", + SWT.LEFT, + "timed-wait", //$NON-NLS-1$ + PREFS_THREAD_COL_STATUS, store); + + TableHelper.createTableColumn( + mThreadTable, + "utime", + SWT.RIGHT, + "utime", //$NON-NLS-1$ + PREFS_THREAD_COL_UTIME, store); + + TableHelper.createTableColumn( + mThreadTable, + "stime", + SWT.RIGHT, + "utime", //$NON-NLS-1$ + PREFS_THREAD_COL_STIME, store); + + TableHelper.createTableColumn( + mThreadTable, + "Name", + SWT.LEFT, + "android.class.ReallyLongClassName.MethodName", //$NON-NLS-1$ + PREFS_THREAD_COL_NAME, store); + + mThreadViewer = new TableViewer(mThreadTable); + mThreadViewer.setContentProvider(new ThreadContentProvider()); + mThreadViewer.setLabelProvider(new ThreadLabelProvider()); + + mThreadViewer.addSelectionChangedListener(new ISelectionChangedListener() { + @Override + public void selectionChanged(SelectionChangedEvent event) { + ThreadInfo selectedThread = getThreadSelection(event.getSelection()); + updateThreadStackTrace(selectedThread); + } + }); + mThreadViewer.addDoubleClickListener(new IDoubleClickListener() { + @Override + public void doubleClick(DoubleClickEvent event) { + ThreadInfo selectedThread = getThreadSelection(event.getSelection()); + if (selectedThread != null) { + Client client = (Client)mThreadViewer.getInput(); + + if (client != null) { + client.requestThreadStackTrace(selectedThread.getThreadId()); + } + } + } + }); + + // the separating sash + final Sash sash = new Sash(mThreadBase, SWT.HORIZONTAL); + Color darkGray = parent.getDisplay().getSystemColor(SWT.COLOR_DARK_GRAY); + sash.setBackground(darkGray); + + // the UI below the sash + mStackTraceBase = new Composite(mThreadBase, SWT.NONE); + mStackTraceBase.setLayout(new GridLayout(2, false)); + + mRefreshStackTraceButton = new Button(mStackTraceBase, SWT.PUSH); + mRefreshStackTraceButton.setText("Refresh"); + mRefreshStackTraceButton.addSelectionListener(new SelectionAdapter() { + @Override + public void widgetSelected(SelectionEvent e) { + ThreadInfo selectedThread = getThreadSelection(null); + if (selectedThread != null) { + Client currentClient = getCurrentClient(); + if (currentClient != null) { + currentClient.requestThreadStackTrace(selectedThread.getThreadId()); + } + } + } + }); + + mStackTraceTimeLabel = new Label(mStackTraceBase, SWT.NONE); + mStackTraceTimeLabel.setLayoutData(new GridData(GridData.FILL_HORIZONTAL)); + + mStackTracePanel = new StackTracePanel(); + mStackTraceTable = mStackTracePanel.createPanel(mStackTraceBase, + PREFS_STACK_COL_CLASS, + PREFS_STACK_COL_METHOD, + PREFS_STACK_COL_FILE, + PREFS_STACK_COL_LINE, + PREFS_STACK_COL_NATIVE, + store); + + GridData gd; + mStackTraceTable.setLayoutData(gd = new GridData(GridData.FILL_BOTH)); + gd.horizontalSpan = 2; + + // now setup the sash. + // form layout data + FormData data = new FormData(); + data.top = new FormAttachment(0, 0); + data.bottom = new FormAttachment(sash, 0); + data.left = new FormAttachment(0, 0); + data.right = new FormAttachment(100, 0); + mThreadTable.setLayoutData(data); + + final FormData sashData = new FormData(); + if (store != null && store.contains(PREFS_THREAD_SASH)) { + sashData.top = new FormAttachment(0, store.getInt(PREFS_THREAD_SASH)); + } else { + sashData.top = new FormAttachment(50,0); // 50% across + } + sashData.left = new FormAttachment(0, 0); + sashData.right = new FormAttachment(100, 0); + sash.setLayoutData(sashData); + + data = new FormData(); + data.top = new FormAttachment(sash, 0); + data.bottom = new FormAttachment(100, 0); + data.left = new FormAttachment(0, 0); + data.right = new FormAttachment(100, 0); + mStackTraceBase.setLayoutData(data); + + // allow resizes, but cap at minPanelWidth + sash.addListener(SWT.Selection, new Listener() { + @Override + public void handleEvent(Event e) { + Rectangle sashRect = sash.getBounds(); + Rectangle panelRect = mThreadBase.getClientArea(); + int bottom = panelRect.height - sashRect.height - 100; + e.y = Math.max(Math.min(e.y, bottom), 100); + if (e.y != sashRect.y) { + sashData.top = new FormAttachment(0, e.y); + store.setValue(PREFS_THREAD_SASH, e.y); + mThreadBase.layout(); + } + } + }); + + ((StackLayout)mBase.getLayout()).topControl = mNotSelected; + + return mBase; + } + + /** + * Sets the focus to the proper control inside the panel. + */ + @Override + public void setFocus() { + mThreadTable.setFocus(); + } + + /** + * Sent when an existing client information changed. + *

+ * This is sent from a non UI thread. + * @param client the updated client. + * @param changeMask the bit mask describing the changed properties. It can contain + * any of the following values: {@link Client#CHANGE_INFO}, {@link Client#CHANGE_NAME} + * {@link Client#CHANGE_DEBUGGER_STATUS}, {@link Client#CHANGE_THREAD_MODE}, + * {@link Client#CHANGE_THREAD_DATA}, {@link Client#CHANGE_HEAP_MODE}, + * {@link Client#CHANGE_HEAP_DATA}, {@link Client#CHANGE_NATIVE_HEAP_DATA} + * + * @see IClientChangeListener#clientChanged(Client, int) + */ + @Override + public void clientChanged(final Client client, int changeMask) { + if (client == getCurrentClient()) { + if ((changeMask & Client.CHANGE_THREAD_MODE) != 0 || + (changeMask & Client.CHANGE_THREAD_DATA) != 0) { + try { + mThreadTable.getDisplay().asyncExec(new Runnable() { + @Override + public void run() { + clientSelected(); + } + }); + } catch (SWTException e) { + // widget is disposed, we do nothing + } + } else if ((changeMask & Client.CHANGE_THREAD_STACKTRACE) != 0) { + try { + mThreadTable.getDisplay().asyncExec(new Runnable() { + @Override + public void run() { + updateThreadStackCall(); + } + }); + } catch (SWTException e) { + // widget is disposed, we do nothing + } + } + } + } + + /** + * Sent when a new device is selected. The new device can be accessed + * with {@link #getCurrentDevice()}. + */ + @Override + public void deviceSelected() { + // pass + } + + /** + * Sent when a new client is selected. The new client can be accessed + * with {@link #getCurrentClient()}. + */ + @Override + public void clientSelected() { + if (mThreadTable.isDisposed()) { + return; + } + + Client client = getCurrentClient(); + + mStackTracePanel.setCurrentClient(client); + + if (client != null) { + if (!client.isThreadUpdateEnabled()) { + ((StackLayout)mBase.getLayout()).topControl = mNotEnabled; + mThreadViewer.setInput(null); + + // if we are currently updating the thread, stop doing it. + mMustStopRecurringThreadUpdate = true; + } else { + ((StackLayout)mBase.getLayout()).topControl = mThreadBase; + mThreadViewer.setInput(client); + + synchronized (mLock) { + // if we're not updating we start the process + if (mRecurringThreadUpdateRunning == false) { + startRecurringThreadUpdate(); + } else if (mMustStopRecurringThreadUpdate) { + // else if there's a runnable that's still going to get called, lets + // simply cancel the stop, and keep going + mMustStopRecurringThreadUpdate = false; + } + } + } + } else { + ((StackLayout)mBase.getLayout()).topControl = mNotSelected; + mThreadViewer.setInput(null); + } + + mBase.layout(); + } + + /** + * Updates the stack call of the currently selected thread. + *

+ * This must be called from the UI thread. + */ + private void updateThreadStackCall() { + Client client = getCurrentClient(); + if (client != null) { + // get the current selection in the ThreadTable + ThreadInfo selectedThread = getThreadSelection(null); + + if (selectedThread != null) { + updateThreadStackTrace(selectedThread); + } else { + updateThreadStackTrace(null); + } + } + } + + /** + * updates the stackcall of the specified thread. If null the UI is emptied + * of current data. + * @param thread + */ + private void updateThreadStackTrace(ThreadInfo thread) { + mStackTracePanel.setViewerInput(thread); + + if (thread != null) { + mRefreshStackTraceButton.setEnabled(true); + long stackcallTime = thread.getStackCallTime(); + if (stackcallTime != 0) { + String label = new Date(stackcallTime).toString(); + mStackTraceTimeLabel.setText(label); + } else { + mStackTraceTimeLabel.setText(""); //$NON-NLS-1$ + } + } else { + mRefreshStackTraceButton.setEnabled(true); + mStackTraceTimeLabel.setText(""); //$NON-NLS-1$ + } + } + + @Override + protected void setTableFocusListener() { + addTableToFocusListener(mThreadTable); + addTableToFocusListener(mStackTraceTable); + } + + /** + * Initiate recurring events. We use a shorter "initialWait" so we do the + * first execution sooner. We don't do it immediately because we want to + * give the clients a chance to get set up. + */ + private void startRecurringThreadUpdate() { + mRecurringThreadUpdateRunning = true; + int initialWait = 1000; + + mDisplay.timerExec(initialWait, new Runnable() { + @Override + public void run() { + synchronized (mLock) { + // lets check we still want updates. + if (mMustStopRecurringThreadUpdate == false) { + Client client = getCurrentClient(); + if (client != null) { + client.requestThreadUpdate(); + + mDisplay.timerExec( + DdmUiPreferences.getThreadRefreshInterval() * 1000, this); + } else { + // we don't have a Client, which means the runnable is not + // going to be called through the timer. We reset the running flag. + mRecurringThreadUpdateRunning = false; + } + } else { + // else actually stops (don't call the timerExec) and reset the flags. + mRecurringThreadUpdateRunning = false; + mMustStopRecurringThreadUpdate = false; + } + } + } + }); + } + + /** + * Returns the current thread selection or null if none is found. + * If a {@link ISelection} object is specified, the first {@link ThreadInfo} from this selection + * is returned, otherwise, the ISelection returned by + * {@link TableViewer#getSelection()} is used. + * @param selection the {@link ISelection} to use, or null + */ + private ThreadInfo getThreadSelection(ISelection selection) { + if (selection == null) { + selection = mThreadViewer.getSelection(); + } + + if (selection instanceof IStructuredSelection) { + IStructuredSelection structuredSelection = (IStructuredSelection)selection; + Object object = structuredSelection.getFirstElement(); + if (object instanceof ThreadInfo) { + return (ThreadInfo)object; + } + } + + return null; + } + +} + diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/actions/ICommonAction.java b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/actions/ICommonAction.java new file mode 100644 index 00000000..856b874f --- /dev/null +++ b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/actions/ICommonAction.java @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2007 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.ddmuilib.actions; + +/** + * Common interface for basic action handling. This allows the common ui + * components to access ToolItem or Action the same way. + */ +public interface ICommonAction { + /** + * Sets the enabled state of this action. + * @param enabled true to enable, and + * false to disable + */ + public void setEnabled(boolean enabled); + + /** + * Sets the checked status of this action. + * @param checked the new checked status + */ + public void setChecked(boolean checked); + + /** + * Sets the {@link Runnable} that will be executed when the action is triggered. + */ + public void setRunnable(Runnable runnable); +} + diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/actions/ToolItemAction.java b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/actions/ToolItemAction.java new file mode 100644 index 00000000..c7fef324 --- /dev/null +++ b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/actions/ToolItemAction.java @@ -0,0 +1,71 @@ +/* + * Copyright (C) 2007 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.ddmuilib.actions; + +import org.eclipse.swt.events.SelectionAdapter; +import org.eclipse.swt.events.SelectionEvent; +import org.eclipse.swt.events.SelectionListener; +import org.eclipse.swt.widgets.ToolBar; +import org.eclipse.swt.widgets.ToolItem; + +/** + * Wrapper around {@link ToolItem} to implement {@link ICommonAction} + */ +public class ToolItemAction implements ICommonAction { + public ToolItem item; + + public ToolItemAction(ToolBar parent, int style) { + item = new ToolItem(parent, style); + } + + /** + * Sets the enabled state of this action. + * @param enabled true to enable, and + * false to disable + * @see ICommonAction#setChecked(boolean) + */ + @Override + public void setChecked(boolean checked) { + item.setSelection(checked); + } + + /** + * Sets the enabled state of this action. + * @param enabled true to enable, and + * false to disable + * @see ICommonAction#setEnabled(boolean) + */ + @Override + public void setEnabled(boolean enabled) { + item.setEnabled(enabled); + } + + /** + * Sets the {@link Runnable} that will be executed when the action is triggered (through + * {@link SelectionListener#widgetSelected(SelectionEvent)} on the wrapped {@link ToolItem}). + * @see ICommonAction#setRunnable(Runnable) + */ + @Override + public void setRunnable(final Runnable runnable) { + item.addSelectionListener(new SelectionAdapter() { + @Override + public void widgetSelected(SelectionEvent e) { + runnable.run(); + } + }); + } +} diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/annotation/UiThread.java b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/annotation/UiThread.java new file mode 100644 index 00000000..8e9e11b6 --- /dev/null +++ b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/annotation/UiThread.java @@ -0,0 +1,31 @@ +/* + * Copyright (C) 2008 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.ddmuilib.annotation; + +import java.lang.annotation.Target; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +/** + * Simple utility annotation used only to mark methods that are executed on the UI thread. + * This annotation's sole purpose is to help reading the source code. It has no additional effect. + */ +@Target({ ElementType.METHOD }) +@Retention(RetentionPolicy.SOURCE) +public @interface UiThread { +} diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/annotation/WorkerThread.java b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/annotation/WorkerThread.java new file mode 100644 index 00000000..e767eda7 --- /dev/null +++ b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/annotation/WorkerThread.java @@ -0,0 +1,31 @@ +/* + * Copyright (C) 2008 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.ddmuilib.annotation; + +import java.lang.annotation.Target; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +/** + * Simple utility annotation used only to mark methods that are not executed on the UI thread. + * This annotation's sole purpose is to help reading the source code. It has no additional effect. + */ +@Target({ ElementType.METHOD }) +@Retention(RetentionPolicy.SOURCE) +public @interface WorkerThread { +} diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/console/DdmConsole.java b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/console/DdmConsole.java new file mode 100644 index 00000000..4df4376e --- /dev/null +++ b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/console/DdmConsole.java @@ -0,0 +1,91 @@ +/* + * Copyright (C) 2007 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.ddmuilib.console; + + +/** + * Static Console used to ouput messages. By default outputs the message to System.out and + * System.err, but can receive a IDdmConsole object which will actually do something. + */ +public class DdmConsole { + + private static IDdmConsole mConsole; + + /** + * Prints a message to the android console. + * @param message the message to print + * @param forceDisplay if true, this force the console to be displayed. + */ + public static void printErrorToConsole(String message) { + if (mConsole != null) { + mConsole.printErrorToConsole(message); + } else { + System.err.println(message); + } + } + + /** + * Prints several messages to the android console. + * @param messages the messages to print + * @param forceDisplay if true, this force the console to be displayed. + */ + public static void printErrorToConsole(String[] messages) { + if (mConsole != null) { + mConsole.printErrorToConsole(messages); + } else { + for (String message : messages) { + System.err.println(message); + } + } + } + + /** + * Prints a message to the android console. + * @param message the message to print + * @param forceDisplay if true, this force the console to be displayed. + */ + public static void printToConsole(String message) { + if (mConsole != null) { + mConsole.printToConsole(message); + } else { + System.out.println(message); + } + } + + /** + * Prints several messages to the android console. + * @param messages the messages to print + * @param forceDisplay if true, this force the console to be displayed. + */ + public static void printToConsole(String[] messages) { + if (mConsole != null) { + mConsole.printToConsole(messages); + } else { + for (String message : messages) { + System.out.println(message); + } + } + } + + /** + * Sets a IDdmConsole to override the default behavior of the console + * @param console The new IDdmConsole + * **/ + public static void setConsole(IDdmConsole console) { + mConsole = console; + } +} diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/console/IDdmConsole.java b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/console/IDdmConsole.java new file mode 100644 index 00000000..3679d413 --- /dev/null +++ b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/console/IDdmConsole.java @@ -0,0 +1,47 @@ +/* + * Copyright (C) 2007 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.ddmuilib.console; + + +/** + * DDMS console interface. + */ +public interface IDdmConsole { + /** + * Prints a message to the android console. + * @param message the message to print + */ + public void printErrorToConsole(String message); + + /** + * Prints several messages to the android console. + * @param messages the messages to print + */ + public void printErrorToConsole(String[] messages); + + /** + * Prints a message to the android console. + * @param message the message to print + */ + public void printToConsole(String message); + + /** + * Prints several messages to the android console. + * @param messages the messages to print + */ + public void printToConsole(String[] messages); +} diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/explorer/DeviceContentProvider.java b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/explorer/DeviceContentProvider.java new file mode 100644 index 00000000..062d4f07 --- /dev/null +++ b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/explorer/DeviceContentProvider.java @@ -0,0 +1,177 @@ +/* + * Copyright (C) 2007 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.ddmuilib.explorer; + +import com.android.ddmlib.FileListingService; +import com.android.ddmlib.FileListingService.FileEntry; +import com.android.ddmlib.FileListingService.IListingReceiver; + +import org.eclipse.jface.viewers.ITreeContentProvider; +import org.eclipse.jface.viewers.TreeViewer; +import org.eclipse.jface.viewers.Viewer; +import org.eclipse.swt.widgets.Display; +import org.eclipse.swt.widgets.Tree; + +/** + * Content provider class for device Explorer. + */ +class DeviceContentProvider implements ITreeContentProvider { + + private TreeViewer mViewer; + private FileListingService mFileListingService; + private FileEntry mRootEntry; + + private IListingReceiver sListingReceiver = new IListingReceiver() { + @Override + public void setChildren(final FileEntry entry, FileEntry[] children) { + final Tree t = mViewer.getTree(); + if (t != null && t.isDisposed() == false) { + Display display = t.getDisplay(); + if (display.isDisposed() == false) { + display.asyncExec(new Runnable() { + @Override + public void run() { + if (t.isDisposed() == false) { + // refresh the entry. + mViewer.refresh(entry); + + // force it open, since on linux and windows + // when getChildren() returns null, the node is + // not considered expanded. + mViewer.setExpandedState(entry, true); + } + } + }); + } + } + } + + @Override + public void refreshEntry(final FileEntry entry) { + final Tree t = mViewer.getTree(); + if (t != null && t.isDisposed() == false) { + Display display = t.getDisplay(); + if (display.isDisposed() == false) { + display.asyncExec(new Runnable() { + @Override + public void run() { + if (t.isDisposed() == false) { + // refresh the entry. + mViewer.refresh(entry); + } + } + }); + } + } + } + }; + + /** + * + */ + public DeviceContentProvider() { + } + + public void setListingService(FileListingService fls) { + mFileListingService = fls; + } + + /* (non-Javadoc) + * @see org.eclipse.jface.viewers.ITreeContentProvider#getChildren(java.lang.Object) + */ + @Override + public Object[] getChildren(Object parentElement) { + if (parentElement instanceof FileEntry) { + FileEntry parentEntry = (FileEntry)parentElement; + + Object[] oldEntries = parentEntry.getCachedChildren(); + Object[] newEntries = mFileListingService.getChildren(parentEntry, + true, sListingReceiver); + + if (newEntries != null) { + return newEntries; + } else { + // if null was returned, this means the cache was not valid, + // and a thread was launched for ls. sListingReceiver will be + // notified with the new entries. + return oldEntries; + } + } + return new Object[0]; + } + + /* (non-Javadoc) + * @see org.eclipse.jface.viewers.ITreeContentProvider#getParent(java.lang.Object) + */ + @Override + public Object getParent(Object element) { + if (element instanceof FileEntry) { + FileEntry entry = (FileEntry)element; + + return entry.getParent(); + } + return null; + } + + /* (non-Javadoc) + * @see org.eclipse.jface.viewers.ITreeContentProvider#hasChildren(java.lang.Object) + */ + @Override + public boolean hasChildren(Object element) { + if (element instanceof FileEntry) { + FileEntry entry = (FileEntry)element; + + return entry.getType() == FileListingService.TYPE_DIRECTORY; + } + return false; + } + + /* (non-Javadoc) + * @see org.eclipse.jface.viewers.IStructuredContentProvider#getElements(java.lang.Object) + */ + @Override + public Object[] getElements(Object inputElement) { + if (inputElement instanceof FileEntry) { + FileEntry entry = (FileEntry)inputElement; + if (entry.isRoot()) { + return getChildren(mRootEntry); + } + } + + return null; + } + + /* (non-Javadoc) + * @see org.eclipse.jface.viewers.IContentProvider#dispose() + */ + @Override + public void dispose() { + } + + /* (non-Javadoc) + * @see org.eclipse.jface.viewers.IContentProvider#inputChanged(org.eclipse.jface.viewers.Viewer, java.lang.Object, java.lang.Object) + */ + @Override + public void inputChanged(Viewer viewer, Object oldInput, Object newInput) { + if (viewer instanceof TreeViewer) { + mViewer = (TreeViewer)viewer; + } + if (newInput instanceof FileEntry) { + mRootEntry = (FileEntry)newInput; + } + } +} diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/explorer/DeviceExplorer.java b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/explorer/DeviceExplorer.java new file mode 100644 index 00000000..b69d3b52 --- /dev/null +++ b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/explorer/DeviceExplorer.java @@ -0,0 +1,922 @@ +/* + * Copyright (C) 2007 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.ddmuilib.explorer; + +import com.android.ddmlib.AdbCommandRejectedException; +import com.android.ddmlib.DdmConstants; +import com.android.ddmlib.FileListingService; +import com.android.ddmlib.FileListingService.FileEntry; +import com.android.ddmlib.IDevice; +import com.android.ddmlib.IShellOutputReceiver; +import com.android.ddmlib.ShellCommandUnresponsiveException; +import com.android.ddmlib.SyncException; +import com.android.ddmlib.SyncService; +import com.android.ddmlib.SyncService.ISyncProgressMonitor; +import com.android.ddmlib.TimeoutException; +import com.android.ddmuilib.DdmUiPreferences; +import com.android.ddmuilib.ImageLoader; +import com.android.ddmuilib.Panel; +import com.android.ddmuilib.SyncProgressHelper; +import com.android.ddmuilib.SyncProgressHelper.SyncRunnable; +import com.android.ddmuilib.TableHelper; +import com.android.ddmuilib.actions.ICommonAction; +import com.android.ddmuilib.console.DdmConsole; + +import org.eclipse.core.runtime.IStatus; +import org.eclipse.core.runtime.Status; +import org.eclipse.jface.dialogs.ErrorDialog; +import org.eclipse.jface.dialogs.IInputValidator; +import org.eclipse.jface.dialogs.InputDialog; +import org.eclipse.jface.preference.IPreferenceStore; +import org.eclipse.jface.viewers.DoubleClickEvent; +import org.eclipse.jface.viewers.IDoubleClickListener; +import org.eclipse.jface.viewers.ISelection; +import org.eclipse.jface.viewers.ISelectionChangedListener; +import org.eclipse.jface.viewers.IStructuredSelection; +import org.eclipse.jface.viewers.SelectionChangedEvent; +import org.eclipse.jface.viewers.TreeViewer; +import org.eclipse.jface.viewers.ViewerDropAdapter; +import org.eclipse.swt.SWT; +import org.eclipse.swt.dnd.DND; +import org.eclipse.swt.dnd.FileTransfer; +import org.eclipse.swt.dnd.Transfer; +import org.eclipse.swt.dnd.TransferData; +import org.eclipse.swt.graphics.Image; +import org.eclipse.swt.layout.FillLayout; +import org.eclipse.swt.widgets.Composite; +import org.eclipse.swt.widgets.Control; +import org.eclipse.swt.widgets.DirectoryDialog; +import org.eclipse.swt.widgets.Display; +import org.eclipse.swt.widgets.FileDialog; +import org.eclipse.swt.widgets.Tree; +import org.eclipse.swt.widgets.TreeItem; + +import java.io.BufferedReader; +import java.io.File; +import java.io.IOException; +import java.io.InputStreamReader; +import java.util.ArrayList; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Device filesystem explorer class. + */ +public class DeviceExplorer extends Panel { + + private final static String TRACE_KEY_EXT = ".key"; // $NON-NLS-1S + private final static String TRACE_DATA_EXT = ".data"; // $NON-NLS-1S + + private static Pattern mKeyFilePattern = Pattern.compile( + "(.+)\\" + TRACE_KEY_EXT); // $NON-NLS-1S + private static Pattern mDataFilePattern = Pattern.compile( + "(.+)\\" + TRACE_DATA_EXT); // $NON-NLS-1S + + public static String COLUMN_NAME = "android.explorer.name"; //$NON-NLS-1S + public static String COLUMN_SIZE = "android.explorer.size"; //$NON-NLS-1S + public static String COLUMN_DATE = "android.explorer.data"; //$NON-NLS-1S + public static String COLUMN_TIME = "android.explorer.time"; //$NON-NLS-1S + public static String COLUMN_PERMISSIONS = "android.explorer.permissions"; // $NON-NLS-1S + public static String COLUMN_INFO = "android.explorer.info"; // $NON-NLS-1S + + private Composite mParent; + private TreeViewer mTreeViewer; + private Tree mTree; + private DeviceContentProvider mContentProvider; + + private ICommonAction mPushAction; + private ICommonAction mPullAction; + private ICommonAction mDeleteAction; + private ICommonAction mCreateNewFolderAction; + + private Image mFileImage; + private Image mFolderImage; + private Image mPackageImage; + private Image mOtherImage; + + private IDevice mCurrentDevice; + + private String mDefaultSave; + + public DeviceExplorer() { + } + + /** + * Sets custom images for the device explorer. If none are set then defaults are used. + * This can be useful to set platform-specific explorer icons. + * + * This should be called before {@link #createControl(Composite)}. + * + * @param fileImage the icon to represent a file. + * @param folderImage the icon to represent a folder. + * @param packageImage the icon to represent an apk. + * @param otherImage the icon to represent other types of files. + */ + public void setCustomImages(Image fileImage, Image folderImage, Image packageImage, + Image otherImage) { + mFileImage = fileImage; + mFolderImage = folderImage; + mPackageImage = packageImage; + mOtherImage = otherImage; + } + + /** + * Sets the actions so that the device explorer can enable/disable them based on the current + * selection + * @param pushAction + * @param pullAction + * @param deleteAction + * @param createNewFolderAction + */ + public void setActions(ICommonAction pushAction, ICommonAction pullAction, + ICommonAction deleteAction, ICommonAction createNewFolderAction) { + mPushAction = pushAction; + mPullAction = pullAction; + mDeleteAction = deleteAction; + mCreateNewFolderAction = createNewFolderAction; + } + + /** + * Creates a control capable of displaying some information. This is + * called once, when the application is initializing, from the UI thread. + */ + @Override + protected Control createControl(Composite parent) { + mParent = parent; + parent.setLayout(new FillLayout()); + + ImageLoader loader = ImageLoader.getDdmUiLibLoader(); + if (mFileImage == null) { + mFileImage = loader.loadImage("file.png", mParent.getDisplay()); + } + if (mFolderImage == null) { + mFolderImage = loader.loadImage("folder.png", mParent.getDisplay()); + } + if (mPackageImage == null) { + mPackageImage = loader.loadImage("android.png", mParent.getDisplay()); + } + if (mOtherImage == null) { + // TODO: find a default image for other. + } + + mTree = new Tree(parent, SWT.MULTI | SWT.FULL_SELECTION | SWT.VIRTUAL); + mTree.setHeaderVisible(true); + + IPreferenceStore store = DdmUiPreferences.getStore(); + + // create columns + TableHelper.createTreeColumn(mTree, "Name", SWT.LEFT, + "0000drwxrwxrwx", COLUMN_NAME, store); //$NON-NLS-1$ + TableHelper.createTreeColumn(mTree, "Size", SWT.RIGHT, + "000000", COLUMN_SIZE, store); //$NON-NLS-1$ + TableHelper.createTreeColumn(mTree, "Date", SWT.LEFT, + "2007-08-14", COLUMN_DATE, store); //$NON-NLS-1$ + TableHelper.createTreeColumn(mTree, "Time", SWT.LEFT, + "20:54", COLUMN_TIME, store); //$NON-NLS-1$ + TableHelper.createTreeColumn(mTree, "Permissions", SWT.LEFT, + "drwxrwxrwx", COLUMN_PERMISSIONS, store); //$NON-NLS-1$ + TableHelper.createTreeColumn(mTree, "Info", SWT.LEFT, + "drwxrwxrwx", COLUMN_INFO, store); //$NON-NLS-1$ + + // create the jface wrapper + mTreeViewer = new TreeViewer(mTree); + + // setup data provider + mContentProvider = new DeviceContentProvider(); + mTreeViewer.setContentProvider(mContentProvider); + mTreeViewer.setLabelProvider(new FileLabelProvider(mFileImage, + mFolderImage, mPackageImage, mOtherImage)); + + // setup a listener for selection + mTreeViewer.addSelectionChangedListener(new ISelectionChangedListener() { + @Override + public void selectionChanged(SelectionChangedEvent event) { + ISelection sel = event.getSelection(); + if (sel.isEmpty()) { + mPullAction.setEnabled(false); + mPushAction.setEnabled(false); + mDeleteAction.setEnabled(false); + mCreateNewFolderAction.setEnabled(false); + return; + } + if (sel instanceof IStructuredSelection) { + IStructuredSelection selection = (IStructuredSelection) sel; + Object element = selection.getFirstElement(); + if (element == null) + return; + if (element instanceof FileEntry) { + mPullAction.setEnabled(true); + mPushAction.setEnabled(selection.size() == 1); + if (selection.size() == 1) { + FileEntry entry = (FileEntry) element; + setDeleteEnabledState(entry); + mCreateNewFolderAction.setEnabled(entry.isDirectory()); + } else { + mDeleteAction.setEnabled(false); + } + } + } + } + }); + + // add support for double click + mTreeViewer.addDoubleClickListener(new IDoubleClickListener() { + @Override + public void doubleClick(DoubleClickEvent event) { + ISelection sel = event.getSelection(); + + if (sel instanceof IStructuredSelection) { + IStructuredSelection selection = (IStructuredSelection) sel; + + if (selection.size() == 1) { + FileEntry entry = (FileEntry)selection.getFirstElement(); + String name = entry.getName(); + + FileEntry parentEntry = entry.getParent(); + + // can't really do anything with no parent + if (parentEntry == null) { + return; + } + + // check this is a file like we want. + Matcher m = mKeyFilePattern.matcher(name); + if (m.matches()) { + // get the name w/o the extension + String baseName = m.group(1); + + // add the data extension + String dataName = baseName + TRACE_DATA_EXT; + + FileEntry dataEntry = parentEntry.findChild(dataName); + + handleTraceDoubleClick(baseName, entry, dataEntry); + + } else { + m = mDataFilePattern.matcher(name); + if (m.matches()) { + // get the name w/o the extension + String baseName = m.group(1); + + // add the key extension + String keyName = baseName + TRACE_KEY_EXT; + + FileEntry keyEntry = parentEntry.findChild(keyName); + + handleTraceDoubleClick(baseName, keyEntry, entry); + } + } + } + } + } + }); + + // setup drop listener + mTreeViewer.addDropSupport(DND.DROP_COPY | DND.DROP_MOVE, + new Transfer[] { FileTransfer.getInstance() }, + new ViewerDropAdapter(mTreeViewer) { + @Override + public boolean performDrop(Object data) { + // get the item on which we dropped the item(s) + FileEntry target = (FileEntry)getCurrentTarget(); + + // in case we drop at the same level as root + if (target == null) { + return false; + } + + // if the target is not a directory, we get the parent directory + if (target.isDirectory() == false) { + target = target.getParent(); + } + + if (target == null) { + return false; + } + + // get the list of files to drop + String[] files = (String[])data; + + // do the drop + pushFiles(files, target); + + // we need to finish with a refresh + refresh(target); + + return true; + } + + @Override + public boolean validateDrop(Object target, int operation, TransferData transferType) { + if (target == null) { + return false; + } + + // convert to the real item + FileEntry targetEntry = (FileEntry)target; + + // if the target is not a directory, we get the parent directory + if (targetEntry.isDirectory() == false) { + target = targetEntry.getParent(); + } + + if (target == null) { + return false; + } + + return true; + } + }); + + // create and start the refresh thread + new Thread("Device Ls refresher") { + @Override + public void run() { + while (true) { + try { + sleep(FileListingService.REFRESH_RATE); + } catch (InterruptedException e) { + return; + } + + if (mTree != null && mTree.isDisposed() == false) { + Display display = mTree.getDisplay(); + if (display.isDisposed() == false) { + display.asyncExec(new Runnable() { + @Override + public void run() { + if (mTree.isDisposed() == false) { + mTreeViewer.refresh(true); + } + } + }); + } else { + return; + } + } else { + return; + } + } + + } + }.start(); + + return mTree; + } + + @Override + protected void postCreation() { + + } + + /** + * Sets the focus to the proper control inside the panel. + */ + @Override + public void setFocus() { + mTree.setFocus(); + } + + /** + * Processes a double click on a trace file + * @param baseName the base name of the 2 files. + * @param keyEntry The FileEntry for the .key file. + * @param dataEntry The FileEntry for the .data file. + */ + private void handleTraceDoubleClick(String baseName, FileEntry keyEntry, + FileEntry dataEntry) { + // first we need to download the files. + File keyFile; + File dataFile; + String path; + try { + // create a temp file for keyFile + File f = File.createTempFile(baseName, DdmConstants.DOT_TRACE); + f.delete(); + f.mkdir(); + + path = f.getAbsolutePath(); + + keyFile = new File(path + File.separator + keyEntry.getName()); + dataFile = new File(path + File.separator + dataEntry.getName()); + } catch (IOException e) { + return; + } + + // download the files + try { + SyncService sync = mCurrentDevice.getSyncService(); + if (sync != null) { + ISyncProgressMonitor monitor = SyncService.getNullProgressMonitor(); + sync.pullFile(keyEntry, keyFile.getAbsolutePath(), monitor); + sync.pullFile(dataEntry, dataFile.getAbsolutePath(), monitor); + + // now that we have the file, we need to launch traceview + String[] command = new String[2]; + command[0] = DdmUiPreferences.getTraceview(); + command[1] = path + File.separator + baseName; + + try { + final Process p = Runtime.getRuntime().exec(command); + + // create a thread for the output + new Thread("Traceview output") { + @Override + public void run() { + // create a buffer to read the stderr output + InputStreamReader is = new InputStreamReader(p.getErrorStream()); + BufferedReader resultReader = new BufferedReader(is); + + // read the lines as they come. if null is returned, it's + // because the process finished + try { + while (true) { + String line = resultReader.readLine(); + if (line != null) { + DdmConsole.printErrorToConsole("Traceview: " + line); + } else { + break; + } + } + // get the return code from the process + p.waitFor(); + } catch (IOException e) { + } catch (InterruptedException e) { + + } + } + }.start(); + + } catch (IOException e) { + } + } + } catch (IOException e) { + DdmConsole.printErrorToConsole(String.format( + "Failed to pull %1$s: %2$s", keyEntry.getName(), e.getMessage())); + return; + } catch (SyncException e) { + if (e.wasCanceled() == false) { + DdmConsole.printErrorToConsole(String.format( + "Failed to pull %1$s: %2$s", keyEntry.getName(), e.getMessage())); + return; + } + } catch (TimeoutException e) { + DdmConsole.printErrorToConsole(String.format( + "Failed to pull %1$s: timeout", keyEntry.getName())); + } catch (AdbCommandRejectedException e) { + DdmConsole.printErrorToConsole(String.format( + "Failed to pull %1$s: %2$s", keyEntry.getName(), e.getMessage())); + } + } + + /** + * Pull the current selection on the local drive. This method displays + * a dialog box to let the user select where to store the file(s) and + * folder(s). + */ + public void pullSelection() { + // get the selection + TreeItem[] items = mTree.getSelection(); + + // name of the single file pull, or null if we're pulling a directory + // or more than one object. + String filePullName = null; + FileEntry singleEntry = null; + + // are we pulling a single file? + if (items.length == 1) { + singleEntry = (FileEntry)items[0].getData(); + if (singleEntry.getType() == FileListingService.TYPE_FILE) { + filePullName = singleEntry.getName(); + } + } + + // where do we save by default? + String defaultPath = mDefaultSave; + if (defaultPath == null) { + defaultPath = System.getProperty("user.home"); //$NON-NLS-1$ + } + + if (filePullName != null) { + FileDialog fileDialog = new FileDialog(mParent.getShell(), SWT.SAVE); + + fileDialog.setText("Get Device File"); + fileDialog.setFileName(filePullName); + fileDialog.setFilterPath(defaultPath); + + String fileName = fileDialog.open(); + if (fileName != null) { + mDefaultSave = fileDialog.getFilterPath(); + + pullFile(singleEntry, fileName); + } + } else { + DirectoryDialog directoryDialog = new DirectoryDialog(mParent.getShell(), SWT.SAVE); + + directoryDialog.setText("Get Device Files/Folders"); + directoryDialog.setFilterPath(defaultPath); + + String directoryName = directoryDialog.open(); + if (directoryName != null) { + pullSelection(items, directoryName); + } + } + } + + /** + * Push new file(s) and folder(s) into the current selection. Current + * selection must be single item. If the current selection is not a + * directory, the parent directory is used. + * This method displays a dialog to let the user choose file to push to + * the device. + */ + public void pushIntoSelection() { + // get the name of the object we're going to pull + TreeItem[] items = mTree.getSelection(); + + if (items.length == 0) { + return; + } + + FileDialog dlg = new FileDialog(mParent.getShell(), SWT.OPEN); + String fileName; + + dlg.setText("Put File on Device"); + + // There should be only one. + FileEntry entry = (FileEntry)items[0].getData(); + dlg.setFileName(entry.getName()); + + String defaultPath = mDefaultSave; + if (defaultPath == null) { + defaultPath = System.getProperty("user.home"); //$NON-NLS-1$ + } + dlg.setFilterPath(defaultPath); + + fileName = dlg.open(); + if (fileName != null) { + mDefaultSave = dlg.getFilterPath(); + + // we need to figure out the remote path based on the current selection type. + String remotePath; + FileEntry toRefresh = entry; + if (entry.isDirectory()) { + remotePath = entry.getFullPath(); + } else { + toRefresh = entry.getParent(); + remotePath = toRefresh.getFullPath(); + } + + pushFile(fileName, remotePath); + mTreeViewer.refresh(toRefresh); + } + } + + public void deleteSelection() { + // get the name of the object we're going to pull + TreeItem[] items = mTree.getSelection(); + + if (items.length != 1) { + return; + } + + FileEntry entry = (FileEntry)items[0].getData(); + final FileEntry parentEntry = entry.getParent(); + + // create the delete command + String command = "rm " + entry.getFullEscapedPath(); //$NON-NLS-1$ + + try { + mCurrentDevice.executeShellCommand(command, new IShellOutputReceiver() { + @Override + public void addOutput(byte[] data, int offset, int length) { + // pass + // TODO get output to display errors if any. + } + + @Override + public void flush() { + mTreeViewer.refresh(parentEntry); + } + + @Override + public boolean isCancelled() { + return false; + } + }); + } catch (IOException e) { + // adb failed somehow, we do nothing. We should be displaying the error from the output + // of the shell command. + } catch (TimeoutException e) { + // adb failed somehow, we do nothing. We should be displaying the error from the output + // of the shell command. + } catch (AdbCommandRejectedException e) { + // adb failed somehow, we do nothing. We should be displaying the error from the output + // of the shell command. + } catch (ShellCommandUnresponsiveException e) { + // adb failed somehow, we do nothing. We should be displaying the error from the output + // of the shell command. + } + + } + + public void createNewFolderInSelection() { + TreeItem[] items = mTree.getSelection(); + + if (items.length != 1) { + return; + } + + final FileEntry entry = (FileEntry) items[0].getData(); + + if (entry.isDirectory()) { + InputDialog inputDialog = new InputDialog(mTree.getShell(), "New Folder", + "Please enter the new folder name", "New Folder", new IInputValidator() { + @Override + public String isValid(String newText) { + if ((newText != null) && (newText.length() > 0) + && (newText.trim().length() > 0) + && (newText.indexOf('/') == -1) + && (newText.indexOf('\\') == -1)) { + return null; + } else { + return "Invalid name"; + } + } + }); + inputDialog.open(); + String value = inputDialog.getValue(); + + if (value != null) { + // create the mkdir command + String command = "mkdir " + entry.getFullEscapedPath() //$NON-NLS-1$ + + FileListingService.FILE_SEPARATOR + FileEntry.escape(value); + + try { + mCurrentDevice.executeShellCommand(command, new IShellOutputReceiver() { + + @Override + public boolean isCancelled() { + return false; + } + + @Override + public void flush() { + mTreeViewer.refresh(entry); + } + + @Override + public void addOutput(byte[] data, int offset, int length) { + String errorMessage; + if (data != null) { + errorMessage = new String(data); + } else { + errorMessage = ""; + } + Status status = new Status(IStatus.ERROR, + "DeviceExplorer", 0, errorMessage, null); //$NON-NLS-1$ + ErrorDialog.openError(mTree.getShell(), "New Folder Error", + "New Folder Error", status); + } + }); + } catch (TimeoutException e) { + // adb failed somehow, we do nothing. We should be + // displaying the error from the output of the shell + // command. + } catch (AdbCommandRejectedException e) { + // adb failed somehow, we do nothing. We should be + // displaying the error from the output of the shell + // command. + } catch (ShellCommandUnresponsiveException e) { + // adb failed somehow, we do nothing. We should be + // displaying the error from the output of the shell + // command. + } catch (IOException e) { + // adb failed somehow, we do nothing. We should be + // displaying the error from the output of the shell + // command. + } + } + } + } + + /** + * Force a full refresh of the explorer. + */ + public void refresh() { + mTreeViewer.refresh(true); + } + + /** + * Sets the new device to explorer + */ + public void switchDevice(final IDevice device) { + if (device != mCurrentDevice) { + mCurrentDevice = device; + // now we change the input. but we need to do that in the + // ui thread. + if (mTree.isDisposed() == false) { + Display d = mTree.getDisplay(); + d.asyncExec(new Runnable() { + @Override + public void run() { + if (mTree.isDisposed() == false) { + // new service + if (mCurrentDevice != null) { + FileListingService fls = mCurrentDevice.getFileListingService(); + mContentProvider.setListingService(fls); + mTreeViewer.setInput(fls.getRoot()); + } + } + } + }); + } + } + } + + /** + * Refresh an entry from a non ui thread. + * @param entry the entry to refresh. + */ + private void refresh(final FileEntry entry) { + Display d = mTreeViewer.getTree().getDisplay(); + d.asyncExec(new Runnable() { + @Override + public void run() { + mTreeViewer.refresh(entry); + } + }); + } + + /** + * Pulls the selection from a device. + * @param items the tree selection the remote file on the device + * @param localDirector the local directory in which to save the files. + */ + private void pullSelection(TreeItem[] items, final String localDirectory) { + try { + final SyncService sync = mCurrentDevice.getSyncService(); + if (sync != null) { + // make a list of the FileEntry. + ArrayList entries = new ArrayList(); + for (TreeItem item : items) { + Object data = item.getData(); + if (data instanceof FileEntry) { + entries.add((FileEntry)data); + } + } + final FileEntry[] entryArray = entries.toArray( + new FileEntry[entries.size()]); + + SyncProgressHelper.run(new SyncRunnable() { + @Override + public void run(ISyncProgressMonitor monitor) + throws SyncException, IOException, TimeoutException { + sync.pull(entryArray, localDirectory, monitor); + } + + @Override + public void close() { + sync.close(); + } + }, "Pulling file(s) from the device", mParent.getShell()); + } + } catch (SyncException e) { + if (e.wasCanceled() == false) { + DdmConsole.printErrorToConsole(String.format( + "Failed to pull selection: %1$s", e.getMessage())); + } + } catch (Exception e) { + DdmConsole.printErrorToConsole( "Failed to pull selection"); + DdmConsole.printErrorToConsole(e.getMessage()); + } + } + + /** + * Pulls a file from a device. + * @param remote the remote file on the device + * @param local the destination filepath + */ + private void pullFile(final FileEntry remote, final String local) { + try { + final SyncService sync = mCurrentDevice.getSyncService(); + if (sync != null) { + SyncProgressHelper.run(new SyncRunnable() { + @Override + public void run(ISyncProgressMonitor monitor) + throws SyncException, IOException, TimeoutException { + sync.pullFile(remote, local, monitor); + } + + @Override + public void close() { + sync.close(); + } + }, String.format("Pulling %1$s from the device", remote.getName()), + mParent.getShell()); + } + } catch (SyncException e) { + if (e.wasCanceled() == false) { + DdmConsole.printErrorToConsole(String.format( + "Failed to pull selection: %1$s", e.getMessage())); + } + } catch (Exception e) { + DdmConsole.printErrorToConsole( "Failed to pull selection"); + DdmConsole.printErrorToConsole(e.getMessage()); + } + } + + /** + * Pushes several files and directory into a remote directory. + * @param localFiles + * @param remoteDirectory + */ + private void pushFiles(final String[] localFiles, final FileEntry remoteDirectory) { + try { + final SyncService sync = mCurrentDevice.getSyncService(); + if (sync != null) { + SyncProgressHelper.run(new SyncRunnable() { + @Override + public void run(ISyncProgressMonitor monitor) + throws SyncException, IOException, TimeoutException { + sync.push(localFiles, remoteDirectory, monitor); + } + + @Override + public void close() { + sync.close(); + } + }, "Pushing file(s) to the device", mParent.getShell()); + } + } catch (SyncException e) { + if (e.wasCanceled() == false) { + DdmConsole.printErrorToConsole(String.format( + "Failed to push selection: %1$s", e.getMessage())); + } + } catch (Exception e) { + DdmConsole.printErrorToConsole("Failed to push the items"); + DdmConsole.printErrorToConsole(e.getMessage()); + } + } + + /** + * Pushes a file on a device. + * @param local the local filepath of the file to push + * @param remoteDirectory the remote destination directory on the device + */ + private void pushFile(final String local, final String remoteDirectory) { + try { + final SyncService sync = mCurrentDevice.getSyncService(); + if (sync != null) { + // get the file name + String[] segs = local.split(Pattern.quote(File.separator)); + String name = segs[segs.length-1]; + final String remoteFile = remoteDirectory + FileListingService.FILE_SEPARATOR + + name; + + SyncProgressHelper.run(new SyncRunnable() { + @Override + public void run(ISyncProgressMonitor monitor) + throws SyncException, IOException, TimeoutException { + sync.pushFile(local, remoteFile, monitor); + } + + @Override + public void close() { + sync.close(); + } + }, String.format("Pushing %1$s to the device.", name), mParent.getShell()); + } + } catch (SyncException e) { + if (e.wasCanceled() == false) { + DdmConsole.printErrorToConsole(String.format( + "Failed to push selection: %1$s", e.getMessage())); + } + } catch (Exception e) { + DdmConsole.printErrorToConsole("Failed to push the item(s)."); + DdmConsole.printErrorToConsole(e.getMessage()); + } + } + + /** + * Sets the enabled state based on a FileEntry properties + * @param element The selected FileEntry + */ + protected void setDeleteEnabledState(FileEntry element) { + mDeleteAction.setEnabled(element.getType() == FileListingService.TYPE_FILE); + } +} diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/explorer/FileLabelProvider.java b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/explorer/FileLabelProvider.java new file mode 100644 index 00000000..1240e59b --- /dev/null +++ b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/explorer/FileLabelProvider.java @@ -0,0 +1,160 @@ +/* + * Copyright (C) 2007 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.ddmuilib.explorer; + +import com.android.ddmlib.FileListingService; +import com.android.ddmlib.FileListingService.FileEntry; + +import org.eclipse.jface.viewers.ILabelProvider; +import org.eclipse.jface.viewers.ILabelProviderListener; +import org.eclipse.jface.viewers.ITableLabelProvider; +import org.eclipse.swt.graphics.Image; + +/** + * Label provider for the FileEntry. + */ +class FileLabelProvider implements ILabelProvider, ITableLabelProvider { + + private Image mFileImage; + private Image mFolderImage; + private Image mPackageImage; + private Image mOtherImage; + + /** + * Creates Label provider with custom images. + * @param fileImage the Image to represent a file + * @param folderImage the Image to represent a folder + * @param packageImage the Image to represent a .apk file. If null, + * fileImage is used instead. + * @param otherImage the Image to represent all other entry type. + */ + public FileLabelProvider(Image fileImage, Image folderImage, + Image packageImage, Image otherImage) { + mFileImage = fileImage; + mFolderImage = folderImage; + mOtherImage = otherImage; + if (packageImage != null) { + mPackageImage = packageImage; + } else { + mPackageImage = fileImage; + } + } + + /** + * Creates a label provider with default images. + * + */ + public FileLabelProvider() { + + } + + /* (non-Javadoc) + * @see org.eclipse.jface.viewers.ILabelProvider#getImage(java.lang.Object) + */ + @Override + public Image getImage(Object element) { + return null; + } + + /* (non-Javadoc) + * @see org.eclipse.jface.viewers.ILabelProvider#getText(java.lang.Object) + */ + @Override + public String getText(Object element) { + return null; + } + + @Override + public Image getColumnImage(Object element, int columnIndex) { + if (columnIndex == 0) { + if (element instanceof FileEntry) { + FileEntry entry = (FileEntry)element; + switch (entry.getType()) { + case FileListingService.TYPE_FILE: + case FileListingService.TYPE_LINK: + // get the name and extension + if (entry.isApplicationPackage()) { + return mPackageImage; + } + return mFileImage; + case FileListingService.TYPE_DIRECTORY: + case FileListingService.TYPE_DIRECTORY_LINK: + return mFolderImage; + } + } + + // default case return a different image. + return mOtherImage; + } + return null; + } + + @Override + public String getColumnText(Object element, int columnIndex) { + if (element instanceof FileEntry) { + FileEntry entry = (FileEntry)element; + + switch (columnIndex) { + case 0: + return entry.getName(); + case 1: + return entry.getSize(); + case 2: + return entry.getDate(); + case 3: + return entry.getTime(); + case 4: + return entry.getPermissions(); + case 5: + return entry.getInfo(); + } + } + return null; + } + + /* (non-Javadoc) + * @see org.eclipse.jface.viewers.IBaseLabelProvider#addListener(org.eclipse.jface.viewers.ILabelProviderListener) + */ + @Override + public void addListener(ILabelProviderListener listener) { + // we don't need listeners. + } + + /* (non-Javadoc) + * @see org.eclipse.jface.viewers.IBaseLabelProvider#dispose() + */ + @Override + public void dispose() { + } + + /* (non-Javadoc) + * @see org.eclipse.jface.viewers.IBaseLabelProvider#isLabelProperty(java.lang.Object, java.lang.String) + */ + @Override + public boolean isLabelProperty(Object element, String property) { + return false; + } + + /* (non-Javadoc) + * @see org.eclipse.jface.viewers.IBaseLabelProvider#removeListener(org.eclipse.jface.viewers.ILabelProviderListener) + */ + @Override + public void removeListener(ILabelProviderListener listener) { + // we don't need listeners + } + +} diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/handler/BaseFileHandler.java b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/handler/BaseFileHandler.java new file mode 100644 index 00000000..f50a94cf --- /dev/null +++ b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/handler/BaseFileHandler.java @@ -0,0 +1,184 @@ +/* + * Copyright (C) 2009 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.ddmuilib.handler; + +import com.android.ddmlib.ClientData.IHprofDumpHandler; +import com.android.ddmlib.ClientData.IMethodProfilingHandler; +import com.android.ddmlib.SyncException; +import com.android.ddmlib.SyncService; +import com.android.ddmlib.SyncService.ISyncProgressMonitor; +import com.android.ddmlib.TimeoutException; +import com.android.ddmuilib.SyncProgressHelper; +import com.android.ddmuilib.SyncProgressHelper.SyncRunnable; + +import org.eclipse.jface.dialogs.MessageDialog; +import org.eclipse.swt.SWT; +import org.eclipse.swt.widgets.Display; +import org.eclipse.swt.widgets.FileDialog; +import org.eclipse.swt.widgets.Shell; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.lang.reflect.InvocationTargetException; + +/** + * Base handler class for handler dealing with files located on a device. + * + * @see IHprofDumpHandler + * @see IMethodProfilingHandler + */ +public abstract class BaseFileHandler { + + protected final Shell mParentShell; + + public BaseFileHandler(Shell parentShell) { + mParentShell = parentShell; + } + + protected abstract String getDialogTitle(); + + /** + * Prompts the user for a save location and pulls the remote files into this location. + *

This must be called from the UI Thread. + * @param sync the {@link SyncService} to use to pull the file from the device + * @param localFileName The default local name + * @param remoteFilePath The name of the file to pull off of the device + * @param title The title of the File Save dialog. + * @return The result of the pull as a {@link SyncResult} object, or null if the sync + * didn't happen (canceled by the user). + * @throws InvocationTargetException + * @throws InterruptedException + * @throws SyncException if an error happens during the push of the package on the device. + * @throws IOException + */ + protected void promptAndPull(final SyncService sync, + String localFileName, final String remoteFilePath, String title) + throws InvocationTargetException, InterruptedException, SyncException, TimeoutException, + IOException { + FileDialog fileDialog = new FileDialog(mParentShell, SWT.SAVE); + + fileDialog.setText(title); + fileDialog.setFileName(localFileName); + + final String localFilePath = fileDialog.open(); + if (localFilePath != null) { + SyncProgressHelper.run(new SyncRunnable() { + @Override + public void run(ISyncProgressMonitor monitor) throws SyncException, IOException, + TimeoutException { + sync.pullFile(remoteFilePath, localFilePath, monitor); + } + + @Override + public void close() { + sync.close(); + } + }, + String.format("Pulling %1$s from the device", remoteFilePath), mParentShell); + } + } + + /** + * Prompts the user for a save location and copies a temp file into it. + *

This must be called from the UI Thread. + * @param localFileName The default local name + * @param tempFilePath The name of the temp file to copy. + * @param title The title of the File Save dialog. + * @return true if success, false on error or cancel. + */ + protected boolean promptAndSave(String localFileName, byte[] data, String title) { + FileDialog fileDialog = new FileDialog(mParentShell, SWT.SAVE); + + fileDialog.setText(title); + fileDialog.setFileName(localFileName); + + String localFilePath = fileDialog.open(); + if (localFilePath != null) { + try { + saveFile(data, new File(localFilePath)); + return true; + } catch (IOException e) { + String errorMsg = e.getMessage(); + displayErrorInUiThread( + "Failed to save file '%1$s'%2$s", + localFilePath, + errorMsg != null ? ":\n" + errorMsg : "."); + } + } + + return false; + } + + /** + * Display an error message. + *

This will call about to {@link Display} to run this in an async {@link Runnable} in the + * UI Thread. This is safe to be called from a non-UI Thread. + * @param format the string to display + * @param args the string arguments + */ + protected void displayErrorInUiThread(final String format, final Object... args) { + mParentShell.getDisplay().asyncExec(new Runnable() { + @Override + public void run() { + MessageDialog.openError(mParentShell, getDialogTitle(), + String.format(format, args)); + } + }); + } + + /** + * Display an error message. + * This must be called from the UI Thread. + * @param format the string to display + * @param args the string arguments + */ + protected void displayErrorFromUiThread(final String format, final Object... args) { + MessageDialog.openError(mParentShell, getDialogTitle(), + String.format(format, args)); + } + + /** + * Saves a given data into a temp file and returns its corresponding {@link File} object. + * @param data the data to save + * @return the File into which the data was written or null if it failed. + * @throws IOException + */ + protected File saveTempFile(byte[] data, String extension) throws IOException { + File f = File.createTempFile("ddms", extension); + saveFile(data, f); + return f; + } + + /** + * Saves some data into a given File. + * @param data the data to save + * @param output the file into the data is saved. + * @throws IOException + */ + protected void saveFile(byte[] data, File output) throws IOException { + FileOutputStream fos = null; + try { + fos = new FileOutputStream(output); + fos.write(data); + } finally { + if (fos != null) { + fos.close(); + } + } + } +} diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/handler/MethodProfilingHandler.java b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/handler/MethodProfilingHandler.java new file mode 100644 index 00000000..ab1b5f7f --- /dev/null +++ b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/handler/MethodProfilingHandler.java @@ -0,0 +1,195 @@ +/* + * Copyright (C) 2009 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.ddmuilib.handler; + +import com.android.ddmlib.Client; +import com.android.ddmlib.ClientData.IMethodProfilingHandler; +import com.android.ddmlib.DdmConstants; +import com.android.ddmlib.IDevice; +import com.android.ddmlib.Log; +import com.android.ddmlib.SyncException; +import com.android.ddmlib.SyncService; +import com.android.ddmlib.SyncService.ISyncProgressMonitor; +import com.android.ddmlib.TimeoutException; +import com.android.ddmuilib.DdmUiPreferences; +import com.android.ddmuilib.SyncProgressHelper; +import com.android.ddmuilib.SyncProgressHelper.SyncRunnable; +import com.android.ddmuilib.console.DdmConsole; + +import org.eclipse.swt.widgets.Shell; + +import java.io.BufferedReader; +import java.io.File; +import java.io.IOException; +import java.io.InputStreamReader; +import java.lang.reflect.InvocationTargetException; + +/** + * Handler for Method tracing. + * This will pull the trace file into a temp file and launch traceview. + */ +public class MethodProfilingHandler extends BaseFileHandler + implements IMethodProfilingHandler { + + public MethodProfilingHandler(Shell parentShell) { + super(parentShell); + } + + @Override + protected String getDialogTitle() { + return "Method Profiling Error"; + } + + @Override + public void onStartFailure(final Client client, final String message) { + displayErrorInUiThread( + "Unable to create Method Profiling file for application '%1$s'\n\n%2$s" + + "Check logcat for more information.", + client.getClientData().getClientDescription(), + message != null ? message + "\n\n" : ""); + } + + @Override + public void onEndFailure(final Client client, final String message) { + displayErrorInUiThread( + "Unable to finish Method Profiling for application '%1$s'\n\n%2$s" + + "Check logcat for more information.", + client.getClientData().getClientDescription(), + message != null ? message + "\n\n" : ""); + } + + @Override + public void onSuccess(final String remoteFilePath, final Client client) { + mParentShell.getDisplay().asyncExec(new Runnable() { + @Override + public void run() { + if (remoteFilePath == null) { + displayErrorFromUiThread( + "Unable to download trace file: unknown file name.\n" + + "This can happen if you disconnected the device while recording the trace."); + return; + } + + final IDevice device = client.getDevice(); + try { + // get the sync service to pull the HPROF file + final SyncService sync = client.getDevice().getSyncService(); + if (sync != null) { + pullAndOpen(sync, remoteFilePath); + } else { + displayErrorFromUiThread( + "Unable to download trace file from device '%1$s'.", + device.getSerialNumber()); + } + } catch (Exception e) { + displayErrorFromUiThread("Unable to download trace file from device '%1$s'.", + device.getSerialNumber()); + } + } + + }); + } + + @Override + public void onSuccess(byte[] data, final Client client) { + try { + File tempFile = saveTempFile(data, DdmConstants.DOT_TRACE); + open(tempFile.getAbsolutePath()); + } catch (IOException e) { + String errorMsg = e.getMessage(); + displayErrorInUiThread( + "Failed to save trace data into temp file%1$s", + errorMsg != null ? ":\n" + errorMsg : "."); + } + } + + /** + * pulls and open a file. This is run from the UI thread. + */ + private void pullAndOpen(final SyncService sync, final String remoteFilePath) + throws InvocationTargetException, InterruptedException, IOException { + // get a temp file + File temp = File.createTempFile("android", DdmConstants.DOT_TRACE); //$NON-NLS-1$ + final String tempPath = temp.getAbsolutePath(); + + // pull the file + try { + SyncProgressHelper.run(new SyncRunnable() { + @Override + public void run(ISyncProgressMonitor monitor) + throws SyncException, IOException, TimeoutException { + sync.pullFile(remoteFilePath, tempPath, monitor); + } + + @Override + public void close() { + sync.close(); + } + }, + String.format("Pulling %1$s from the device", remoteFilePath), mParentShell); + + // open the temp file in traceview + open(tempPath); + } catch (SyncException e) { + if (e.wasCanceled() == false) { + displayErrorFromUiThread("Unable to download trace file:\n\n%1$s", e.getMessage()); + } + } catch (TimeoutException e) { + displayErrorFromUiThread("Unable to download trace file:\n\ntimeout"); + } + } + + protected void open(String tempPath) { + // now that we have the file, we need to launch traceview + String[] command = new String[2]; + command[0] = DdmUiPreferences.getTraceview(); + command[1] = tempPath; + + try { + final Process p = Runtime.getRuntime().exec(command); + + // create a thread for the output + new Thread("Traceview output") { + @Override + public void run() { + // create a buffer to read the stderr output + InputStreamReader is = new InputStreamReader(p.getErrorStream()); + BufferedReader resultReader = new BufferedReader(is); + + // read the lines as they come. if null is returned, it's + // because the process finished + try { + while (true) { + String line = resultReader.readLine(); + if (line != null) { + DdmConsole.printErrorToConsole("Traceview: " + line); + } else { + break; + } + } + // get the return code from the process + p.waitFor(); + } catch (Exception e) { + Log.e("traceview", e); + } + } + }.start(); + } catch (IOException e) { + Log.e("traceview", e); + } + } +} diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/heap/NativeHeapDataImporter.java b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/heap/NativeHeapDataImporter.java new file mode 100644 index 00000000..88db5cc7 --- /dev/null +++ b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/heap/NativeHeapDataImporter.java @@ -0,0 +1,222 @@ +/* + * Copyright (C) 2011 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.ddmuilib.heap; + +import com.android.ddmlib.NativeAllocationInfo; +import com.android.ddmlib.NativeStackCallInfo; + +import org.eclipse.core.runtime.IProgressMonitor; +import org.eclipse.jface.operation.IRunnableWithProgress; + +import java.io.IOException; +import java.io.LineNumberReader; +import java.io.Reader; +import java.lang.reflect.InvocationTargetException; +import java.util.ArrayList; +import java.util.InputMismatchException; +import java.util.List; +import java.util.Scanner; +import java.util.regex.Pattern; + +public class NativeHeapDataImporter implements IRunnableWithProgress { + private LineNumberReader mReader; + private int mStartLineNumber; + private int mEndLineNumber; + + private NativeHeapSnapshot mSnapshot; + + public NativeHeapDataImporter(Reader stream) { + mReader = new LineNumberReader(stream); + mReader.setLineNumber(1); // start numbering at 1 + } + + @Override + public void run(IProgressMonitor monitor) + throws InvocationTargetException, InterruptedException { + monitor.beginTask("Importing Heap Data", IProgressMonitor.UNKNOWN); + + List allocations = new ArrayList(); + try { + while (true) { + String line; + StringBuilder sb = new StringBuilder(); + + // read in a sequence of lines corresponding to a single NativeAllocationInfo + mStartLineNumber = mReader.getLineNumber(); + while ((line = mReader.readLine()) != null) { + if (line.trim().length() == 0) { + // each block of allocations end with an empty line + break; + } + + sb.append(line); + sb.append('\n'); + } + mEndLineNumber = mReader.getLineNumber(); + + // parse those lines into a NativeAllocationInfo object + String allocationBlock = sb.toString(); + if (allocationBlock.trim().length() > 0) { + allocations.add(getNativeAllocation(allocationBlock)); + } + + if (line == null) { // EOF + break; + } + } + } catch (Exception e) { + if (e.getMessage() == null) { + e = new RuntimeException(genericErrorMessage("Unexpected Parse error")); + } + throw new InvocationTargetException(e); + } finally { + try { + mReader.close(); + } catch (IOException e) { + // we can ignore this exception + } + monitor.done(); + } + + mSnapshot = new NativeHeapSnapshot(allocations); + } + + /** Parse a single native allocation dump. This is the complement of + * {@link NativeAllocationInfo#toString()}. + * + * An allocation is of the following form: + * Allocations: 1 + * Size: 344748 + * Total Size: 344748 + * BeginStackTrace: + * 40069bd8 /lib/libc_malloc_leak.so --- get_backtrace --- /libc/bionic/malloc_leak.c:258 + * 40069dd8 /lib/libc_malloc_leak.so --- leak_calloc --- /libc/bionic/malloc_leak.c:576 + * 40069bd8 /lib/libc_malloc_leak.so --- 40069bd8 --- + * 40069dd8 /lib/libc_malloc_leak.so --- 40069dd8 --- + * EndStackTrace + * Note that in the above stack trace, the last two lines are examples where the address + * was not resolved. + * + * @param block a string of lines corresponding to a single {@code NativeAllocationInfo} + * @return parse the input and return the corresponding {@link NativeAllocationInfo} + * @throws InputMismatchException if there are any parse errors + */ + private NativeAllocationInfo getNativeAllocation(String block) { + Scanner sc = new Scanner(block); + + String kw = sc.next(); + if (!NativeAllocationInfo.ALLOCATIONS_KW.equals(kw)) { + throw new InputMismatchException( + expectedKeywordErrorMessage(NativeAllocationInfo.ALLOCATIONS_KW, kw)); + } + + int allocations = sc.nextInt(); + + kw = sc.next(); + if (!NativeAllocationInfo.SIZE_KW.equals(kw)) { + throw new InputMismatchException( + expectedKeywordErrorMessage(NativeAllocationInfo.SIZE_KW, kw)); + } + + int size = sc.nextInt(); + + kw = sc.next(); + if (!NativeAllocationInfo.TOTAL_SIZE_KW.equals(kw)) { + throw new InputMismatchException( + expectedKeywordErrorMessage(NativeAllocationInfo.TOTAL_SIZE_KW, kw)); + } + + int totalSize = sc.nextInt(); + if (totalSize != size * allocations) { + throw new InputMismatchException( + genericErrorMessage("Total Size does not match size * # of allocations")); + } + + NativeAllocationInfo info = new NativeAllocationInfo(size, allocations); + + kw = sc.next(); + if (!NativeAllocationInfo.BEGIN_STACKTRACE_KW.equals(kw)) { + throw new InputMismatchException( + expectedKeywordErrorMessage(NativeAllocationInfo.BEGIN_STACKTRACE_KW, kw)); + } + + List stackInfo = new ArrayList(); + Pattern endTracePattern = Pattern.compile(NativeAllocationInfo.END_STACKTRACE_KW); + + while (true) { + long address = sc.nextLong(16); + info.addStackCallAddress(address); + + String library = sc.next(); + sc.next(); // ignore "---" + String method = scanTillSeparator(sc, "---"); + + String filename = ""; + if (!isUnresolved(method, address)) { + filename = sc.next(); + } + + stackInfo.add(new NativeStackCallInfo(address, library, method, filename)); + + if (sc.hasNext(endTracePattern)) { + break; + } + } + + info.setResolvedStackCall(stackInfo); + return info; + } + + private String scanTillSeparator(Scanner sc, String separator) { + StringBuilder sb = new StringBuilder(); + + while (true) { + String token = sc.next(); + if (token.equals(separator)) { + break; + } + + sb.append(token); + + // We do not know the exact delimiter that was skipped over, but we know + // that there was atleast 1 whitespace. Add a single whitespace character + // to account for this. + sb.append(' '); + } + + return sb.toString().trim(); + } + + private boolean isUnresolved(String method, long address) { + // a method is unresolved if it is just the hex representation of the address + return Long.toString(address, 16).equals(method); + } + + private String genericErrorMessage(String message) { + return String.format("%1$s between lines %2$d and %3$d", + message, mStartLineNumber, mEndLineNumber); + } + + private String expectedKeywordErrorMessage(String expected, String actual) { + return String.format("Expected keyword '%1$s', saw '%2$s' between lines %3$d to %4$d.", + expected, actual, mStartLineNumber, mEndLineNumber); + } + + public NativeHeapSnapshot getImportedSnapshot() { + return mSnapshot; + } +} diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/heap/NativeHeapDiffSnapshot.java b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/heap/NativeHeapDiffSnapshot.java new file mode 100644 index 00000000..9eb6ddfb --- /dev/null +++ b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/heap/NativeHeapDiffSnapshot.java @@ -0,0 +1,65 @@ +/* + * Copyright (C) 2011 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.ddmuilib.heap; + +import com.android.ddmlib.NativeAllocationInfo; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** + * Models a heap snapshot that is the difference between two snapshots. + */ +public class NativeHeapDiffSnapshot extends NativeHeapSnapshot { + private long mCommonAllocationsTotalMemory; + + public NativeHeapDiffSnapshot(NativeHeapSnapshot newSnapshot, NativeHeapSnapshot oldSnapshot) { + // The diff snapshots behaves like a snapshot that only contains the new allocations + // not present in the old snapshot + super(getNewAllocations(newSnapshot, oldSnapshot)); + + Set commonAllocations = + new HashSet(oldSnapshot.getAllocations()); + commonAllocations.retainAll(newSnapshot.getAllocations()); + + // Memory common between the old and new snapshots + mCommonAllocationsTotalMemory = getTotalMemory(commonAllocations); + } + + private static List getNewAllocations(NativeHeapSnapshot newSnapshot, + NativeHeapSnapshot oldSnapshot) { + Set allocations = + new HashSet(newSnapshot.getAllocations()); + allocations.removeAll(oldSnapshot.getAllocations()); + return new ArrayList(allocations); + } + + @Override + public String getFormattedMemorySize() { + // for a diff snapshot, we report the following string for display: + // xxx bytes new allocation + yyy bytes retained from previous allocation + // = zzz bytes total + + long newAllocations = getTotalSize(); + return String.format("%s bytes new + %s bytes retained = %s bytes total", + formatMemorySize(newAllocations), + formatMemorySize(mCommonAllocationsTotalMemory), + formatMemorySize(newAllocations + mCommonAllocationsTotalMemory)); + } +} diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/heap/NativeHeapLabelProvider.java b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/heap/NativeHeapLabelProvider.java new file mode 100644 index 00000000..b96fa029 --- /dev/null +++ b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/heap/NativeHeapLabelProvider.java @@ -0,0 +1,112 @@ +/* + * Copyright (C) 2011 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.ddmuilib.heap; + +import com.android.ddmlib.NativeAllocationInfo; +import com.android.ddmlib.NativeStackCallInfo; + +import org.eclipse.jface.viewers.ITableLabelProvider; +import org.eclipse.jface.viewers.LabelProvider; +import org.eclipse.swt.graphics.Image; + +/** + * A Label Provider for the Native Heap TreeViewer in {@link NativeHeapPanel}. + */ +public class NativeHeapLabelProvider extends LabelProvider implements ITableLabelProvider { + private long mTotalSize; + + @Override + public Image getColumnImage(Object arg0, int arg1) { + return null; + } + + @Override + public String getColumnText(Object element, int index) { + if (element instanceof NativeAllocationInfo) { + return getColumnTextForNativeAllocation((NativeAllocationInfo) element, index); + } + + if (element instanceof NativeLibraryAllocationInfo) { + return getColumnTextForNativeLibrary((NativeLibraryAllocationInfo) element, index); + } + + return null; + } + + private String getColumnTextForNativeAllocation(NativeAllocationInfo info, int index) { + NativeStackCallInfo stackInfo = info.getRelevantStackCallInfo(); + + switch (index) { + case 0: + return stackInfo == null ? stackResolutionStatus(info) : stackInfo.getLibraryName(); + case 1: + return Integer.toString(info.getSize() * info.getAllocationCount()); + case 2: + return getPercentageString(info.getSize() * info.getAllocationCount(), mTotalSize); + case 3: + String prefix = ""; + if (!info.isZygoteChild()) { + prefix = "Z "; + } + return prefix + Integer.toString(info.getAllocationCount()); + case 4: + return Integer.toString(info.getSize()); + case 5: + return stackInfo == null ? stackResolutionStatus(info) : stackInfo.getMethodName(); + default: + return null; + } + } + + private String getColumnTextForNativeLibrary(NativeLibraryAllocationInfo info, int index) { + switch (index) { + case 0: + return info.getLibraryName(); + case 1: + return Long.toString(info.getTotalSize()); + case 2: + return getPercentageString(info.getTotalSize(), mTotalSize); + default: + return null; + } + } + + private String getPercentageString(long size, long total) { + if (total == 0) { + return ""; + } + + return String.format("%.1f%%", (float)(size * 100)/(float)total); + } + + private String stackResolutionStatus(NativeAllocationInfo info) { + if (info.isStackCallResolved()) { + return "?"; // resolved and unknown + } else { + return "Resolving..."; // still resolving... + } + } + + /** + * Set the total size of the heap dump for use in percentage calculations. + * This value should be set whenever the input to the tree changes so that the percentages + * are computed correctly. + */ + public void setTotalSize(long totalSize) { + mTotalSize = totalSize; + } +} diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/heap/NativeHeapPanel.java b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/heap/NativeHeapPanel.java new file mode 100644 index 00000000..5f7abe29 --- /dev/null +++ b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/heap/NativeHeapPanel.java @@ -0,0 +1,1152 @@ +/* + * Copyright (C) 2011 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.ddmuilib.heap; + +import com.android.ddmlib.Client; +import com.android.ddmlib.Log; +import com.android.ddmlib.NativeAllocationInfo; +import com.android.ddmlib.NativeLibraryMapInfo; +import com.android.ddmlib.NativeStackCallInfo; +import com.android.ddmuilib.Addr2Line; +import com.android.ddmuilib.BaseHeapPanel; +import com.android.ddmuilib.ITableFocusListener; +import com.android.ddmuilib.ITableFocusListener.IFocusedTableActivator; +import com.android.ddmuilib.ImageLoader; +import com.android.ddmuilib.TableHelper; + +import org.eclipse.jface.dialogs.MessageDialog; +import org.eclipse.jface.dialogs.ProgressMonitorDialog; +import org.eclipse.jface.preference.IPreferenceStore; +import org.eclipse.jface.viewers.TreeViewer; +import org.eclipse.swt.SWT; +import org.eclipse.swt.dnd.Clipboard; +import org.eclipse.swt.dnd.TextTransfer; +import org.eclipse.swt.dnd.Transfer; +import org.eclipse.swt.events.FocusEvent; +import org.eclipse.swt.events.FocusListener; +import org.eclipse.swt.events.ModifyEvent; +import org.eclipse.swt.events.ModifyListener; +import org.eclipse.swt.events.SelectionAdapter; +import org.eclipse.swt.events.SelectionEvent; +import org.eclipse.swt.graphics.Rectangle; +import org.eclipse.swt.layout.FormAttachment; +import org.eclipse.swt.layout.FormData; +import org.eclipse.swt.layout.FormLayout; +import org.eclipse.swt.layout.GridData; +import org.eclipse.swt.layout.GridLayout; +import org.eclipse.swt.widgets.Button; +import org.eclipse.swt.widgets.Combo; +import org.eclipse.swt.widgets.Composite; +import org.eclipse.swt.widgets.Control; +import org.eclipse.swt.widgets.Display; +import org.eclipse.swt.widgets.Event; +import org.eclipse.swt.widgets.FileDialog; +import org.eclipse.swt.widgets.Label; +import org.eclipse.swt.widgets.Listener; +import org.eclipse.swt.widgets.Sash; +import org.eclipse.swt.widgets.Shell; +import org.eclipse.swt.widgets.Text; +import org.eclipse.swt.widgets.ToolBar; +import org.eclipse.swt.widgets.ToolItem; +import org.eclipse.swt.widgets.Tree; +import org.eclipse.swt.widgets.TreeItem; + +import java.io.BufferedWriter; +import java.io.File; +import java.io.FileNotFoundException; +import java.io.FileReader; +import java.io.FileWriter; +import java.io.IOException; +import java.io.PrintWriter; +import java.io.Reader; +import java.lang.reflect.InvocationTargetException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** Panel to display native heap information. */ +public class NativeHeapPanel extends BaseHeapPanel { + private static final boolean USE_OLD_RESOLVER; + static { + String useOldResolver = System.getenv("ANDROID_DDMS_OLD_SYMRESOLVER"); + if (useOldResolver != null && useOldResolver.equalsIgnoreCase("true")) { + USE_OLD_RESOLVER = true; + } else { + USE_OLD_RESOLVER = false; + } + } + private final int MAX_DISPLAYED_ERROR_ITEMS = 5; + + private static final String TOOLTIP_EXPORT_DATA = "Export Heap Data"; + private static final String TOOLTIP_ZYGOTE_ALLOCATIONS = "Show Zygote Allocations"; + private static final String TOOLTIP_DIFFS_ONLY = "Only show new allocations not present in previous snapshot"; + private static final String TOOLTIP_GROUPBY = "Group allocations by library."; + + private static final String EXPORT_DATA_IMAGE = "save.png"; + private static final String ZYGOTE_IMAGE = "zygote.png"; + private static final String DIFFS_ONLY_IMAGE = "diff.png"; + private static final String GROUPBY_IMAGE = "groupby.png"; + + private static final String SNAPSHOT_HEAP_BUTTON_TEXT = "Snapshot Current Native Heap Usage"; + private static final String LOAD_HEAP_DATA_BUTTON_TEXT = "Import Heap Data"; + private static final String SYMBOL_SEARCH_PATH_LABEL_TEXT = "Symbol Search Path:"; + private static final String SYMBOL_SEARCH_PATH_TEXT_MESSAGE = + "List of colon separated paths to search for symbol debug information. See tooltip for examples."; + private static final String SYMBOL_SEARCH_PATH_TOOLTIP_TEXT = + "Colon separated paths that contain unstripped libraries with debug symbols.\n" + + "e.g.: /out/target/product/generic/symbols/system/lib:/path/to/my/app/obj/local/armeabi"; + + private static final String PREFS_SHOW_DIFFS_ONLY = "nativeheap.show.diffs.only"; + private static final String PREFS_SHOW_ZYGOTE_ALLOCATIONS = "nativeheap.show.zygote"; + private static final String PREFS_GROUP_BY_LIBRARY = "nativeheap.grouby.library"; + private static final String PREFS_SYMBOL_SEARCH_PATH = "nativeheap.search.path"; + private static final String PREFS_SASH_HEIGHT_PERCENT = "nativeheap.sash.percent"; + private static final String PREFS_LAST_IMPORTED_HEAPPATH = "nativeheap.last.import.path"; + private IPreferenceStore mPrefStore; + + private List mNativeHeapSnapshots; + + // Maintain the differences between a snapshot and its predecessor. + // mDiffSnapshots[i] = mNativeHeapSnapshots[i] - mNativeHeapSnapshots[i-1] + // The zeroth entry is null since there is no predecessor. + // The list is filled lazily on demand. + private List mDiffSnapshots; + + private Map> mImportedSnapshotsPerPid; + + private Button mSnapshotHeapButton; + private Button mLoadHeapDataButton; + private Text mSymbolSearchPathText; + private Combo mSnapshotIndexCombo; + private Label mMemoryAllocatedText; + + private TreeViewer mDetailsTreeViewer; + private TreeViewer mStackTraceTreeViewer; + private NativeHeapProviderByAllocations mContentProviderByAllocations; + private NativeHeapProviderByLibrary mContentProviderByLibrary; + private NativeHeapLabelProvider mDetailsTreeLabelProvider; + + private ToolBar mDetailsToolBar; + private ToolItem mGroupByButton; + private ToolItem mDiffsOnlyButton; + private ToolItem mShowZygoteAllocationsButton; + private ToolItem mExportHeapDataButton; + + public NativeHeapPanel(IPreferenceStore prefStore) { + mPrefStore = prefStore; + mPrefStore.setDefault(PREFS_SASH_HEIGHT_PERCENT, 75); + mPrefStore.setDefault(PREFS_SYMBOL_SEARCH_PATH, ""); + mPrefStore.setDefault(PREFS_GROUP_BY_LIBRARY, false); + mPrefStore.setDefault(PREFS_SHOW_ZYGOTE_ALLOCATIONS, true); + mPrefStore.setDefault(PREFS_SHOW_DIFFS_ONLY, false); + + mNativeHeapSnapshots = new ArrayList(); + mDiffSnapshots = new ArrayList(); + mImportedSnapshotsPerPid = new HashMap>(); + } + + /** {@inheritDoc} */ + @Override + public void clientChanged(final Client client, int changeMask) { + if (client != getCurrentClient()) { + return; + } + + if ((changeMask & Client.CHANGE_NATIVE_HEAP_DATA) != Client.CHANGE_NATIVE_HEAP_DATA) { + return; + } + + List allocations = client.getClientData().getNativeAllocationList(); + if (allocations.size() == 0) { + return; + } + + // We need to clone this list since getClientData().getNativeAllocationList() clobbers + // the list on future updates + final List nativeAllocations = shallowCloneList(allocations); + + addNativeHeapSnapshot(new NativeHeapSnapshot(nativeAllocations)); + updateDisplay(); + + // Attempt to resolve symbols in a separate thread. + // The UI should be refreshed once the symbols have been resolved. + if (USE_OLD_RESOLVER) { + Thread t = new Thread(new SymbolResolverTask(nativeAllocations, + client.getClientData().getMappedNativeLibraries())); + t.setName("Address to Symbol Resolver"); + t.start(); + } else { + Display.getDefault().asyncExec(new Runnable() { + @Override + public void run() { + resolveSymbols(); + mDetailsTreeViewer.refresh(); + mStackTraceTreeViewer.refresh(); + } + + public void resolveSymbols() { + Shell shell = Display.getDefault().getActiveShell(); + ProgressMonitorDialog d = new ProgressMonitorDialog(shell); + + NativeSymbolResolverTask resolver = new NativeSymbolResolverTask( + nativeAllocations, + client.getClientData().getMappedNativeLibraries(), + mSymbolSearchPathText.getText()); + + try { + d.run(true, true, resolver); + } catch (InvocationTargetException e) { + MessageDialog.openError(shell, + "Error Resolving Symbols", + e.getCause().getMessage()); + return; + } catch (InterruptedException e) { + return; + } + + MessageDialog.openInformation(shell, "Symbol Resolution Status", + getResolutionStatusMessage(resolver)); + } + }); + } + } + + private String getResolutionStatusMessage(NativeSymbolResolverTask resolver) { + StringBuilder sb = new StringBuilder(); + sb.append("Symbol Resolution Complete.\n\n"); + + // show addresses that were not mapped + Set unmappedAddresses = resolver.getUnmappedAddresses(); + if (unmappedAddresses.size() > 0) { + sb.append(String.format("Unmapped addresses (%d): ", + unmappedAddresses.size())); + sb.append(getSampleForDisplay(unmappedAddresses)); + sb.append('\n'); + } + + // show libraries that were not present on disk + Set notFoundLibraries = resolver.getNotFoundLibraries(); + if (notFoundLibraries.size() > 0) { + sb.append(String.format("Libraries not found on disk (%d): ", + notFoundLibraries.size())); + sb.append(getSampleForDisplay(notFoundLibraries)); + sb.append('\n'); + } + + // show addresses that were mapped but not resolved + Set unresolvableAddresses = resolver.getUnresolvableAddresses(); + if (unresolvableAddresses.size() > 0) { + sb.append(String.format("Unresolved addresses (%d): ", + unresolvableAddresses.size())); + sb.append(getSampleForDisplay(unresolvableAddresses)); + sb.append('\n'); + } + + if (resolver.getAddr2LineErrorMessage() != null) { + sb.append("Error launching addr2line: "); + sb.append(resolver.getAddr2LineErrorMessage()); + } + + return sb.toString(); + } + + /** + * Get the string representation for a collection of items. + * If there are more items than {@link #MAX_DISPLAYED_ERROR_ITEMS}, then only the first + * {@link #MAX_DISPLAYED_ERROR_ITEMS} items are taken into account, + * and an ellipsis is added at the end. + */ + private String getSampleForDisplay(Collection items) { + StringBuilder sb = new StringBuilder(); + + int c = 1; + Iterator it = items.iterator(); + while (it.hasNext()) { + Object item = it.next(); + if (item instanceof Long) { + sb.append(String.format("0x%x", item)); + } else { + sb.append(item); + } + + if (c == MAX_DISPLAYED_ERROR_ITEMS && it.hasNext()) { + sb.append(", ..."); + break; + } else if (it.hasNext()) { + sb.append(", "); + } + + c++; + } + return sb.toString(); + } + + private void addNativeHeapSnapshot(NativeHeapSnapshot snapshot) { + mNativeHeapSnapshots.add(snapshot); + + // The diff snapshots are filled in lazily on demand. + // But the list needs to be the same size as mNativeHeapSnapshots, so we add a null. + mDiffSnapshots.add(null); + } + + private List shallowCloneList(List allocations) { + List clonedList = + new ArrayList(allocations.size()); + + for (NativeAllocationInfo i : allocations) { + clonedList.add(i); + } + + return clonedList; + } + + @Override + public void deviceSelected() { + // pass + } + + @Override + public void clientSelected() { + Client c = getCurrentClient(); + + if (c == null) { + // if there is no client selected, then we disable the buttons but leave the + // display as is so that whatever snapshots are displayed continue to stay + // visible to the user. + mSnapshotHeapButton.setEnabled(false); + mLoadHeapDataButton.setEnabled(false); + return; + } + + mNativeHeapSnapshots = new ArrayList(); + mDiffSnapshots = new ArrayList(); + + mSnapshotHeapButton.setEnabled(true); + mLoadHeapDataButton.setEnabled(true); + + List importedSnapshots = mImportedSnapshotsPerPid.get( + c.getClientData().getPid()); + if (importedSnapshots != null) { + for (NativeHeapSnapshot n : importedSnapshots) { + addNativeHeapSnapshot(n); + } + } + + List allocations = c.getClientData().getNativeAllocationList(); + allocations = shallowCloneList(allocations); + + if (allocations.size() > 0) { + addNativeHeapSnapshot(new NativeHeapSnapshot(allocations)); + } + + updateDisplay(); + } + + private void updateDisplay() { + Display.getDefault().syncExec(new Runnable() { + @Override + public void run() { + updateSnapshotIndexCombo(); + updateToolbars(); + + int lastSnapshotIndex = mNativeHeapSnapshots.size() - 1; + displaySnapshot(lastSnapshotIndex); + displayStackTraceForSelection(); + } + }); + } + + private void displaySelectedSnapshot() { + Display.getDefault().syncExec(new Runnable() { + @Override + public void run() { + int idx = mSnapshotIndexCombo.getSelectionIndex(); + displaySnapshot(idx); + } + }); + } + + private void displaySnapshot(int index) { + if (index < 0 || mNativeHeapSnapshots.size() == 0) { + mDetailsTreeViewer.setInput(null); + mMemoryAllocatedText.setText(""); + return; + } + + assert index < mNativeHeapSnapshots.size() : "Invalid snapshot index"; + + NativeHeapSnapshot snapshot = mNativeHeapSnapshots.get(index); + if (mDiffsOnlyButton.getSelection() && index > 0) { + snapshot = getDiffSnapshot(index); + } + + mMemoryAllocatedText.setText(snapshot.getFormattedMemorySize()); + mMemoryAllocatedText.pack(); + + mDetailsTreeLabelProvider.setTotalSize(snapshot.getTotalSize()); + mDetailsTreeViewer.setInput(snapshot); + mDetailsTreeViewer.refresh(); + } + + /** Obtain the diff of snapshot[index] & snapshot[index-1] */ + private NativeHeapSnapshot getDiffSnapshot(int index) { + // if it was already computed, simply return that + NativeHeapSnapshot diffSnapshot = mDiffSnapshots.get(index); + if (diffSnapshot != null) { + return diffSnapshot; + } + + // compute the diff + NativeHeapSnapshot cur = mNativeHeapSnapshots.get(index); + NativeHeapSnapshot prev = mNativeHeapSnapshots.get(index - 1); + diffSnapshot = new NativeHeapDiffSnapshot(cur, prev); + + // cache for future use + mDiffSnapshots.set(index, diffSnapshot); + + return diffSnapshot; + } + + private void updateDisplayGrouping() { + boolean groupByLibrary = mGroupByButton.getSelection(); + mPrefStore.setValue(PREFS_GROUP_BY_LIBRARY, groupByLibrary); + + if (groupByLibrary) { + mDetailsTreeViewer.setContentProvider(mContentProviderByLibrary); + } else { + mDetailsTreeViewer.setContentProvider(mContentProviderByAllocations); + } + } + + private void updateDisplayForZygotes() { + boolean displayZygoteMemory = mShowZygoteAllocationsButton.getSelection(); + mPrefStore.setValue(PREFS_SHOW_ZYGOTE_ALLOCATIONS, displayZygoteMemory); + + // inform the content providers of the zygote display setting + mContentProviderByLibrary.displayZygoteMemory(displayZygoteMemory); + mContentProviderByAllocations.displayZygoteMemory(displayZygoteMemory); + + // refresh the UI + mDetailsTreeViewer.refresh(); + } + + private void updateSnapshotIndexCombo() { + List items = new ArrayList(); + + int numSnapshots = mNativeHeapSnapshots.size(); + for (int i = 0; i < numSnapshots; i++) { + // offset indices by 1 so that users see index starting at 1 rather than 0 + items.add("Snapshot " + (i + 1)); + } + + mSnapshotIndexCombo.setItems(items.toArray(new String[0])); + + if (numSnapshots > 0) { + mSnapshotIndexCombo.setEnabled(true); + mSnapshotIndexCombo.select(numSnapshots - 1); + } else { + mSnapshotIndexCombo.setEnabled(false); + } + } + + private void updateToolbars() { + int numSnapshots = mNativeHeapSnapshots.size(); + mExportHeapDataButton.setEnabled(numSnapshots > 0); + } + + @Override + protected Control createControl(Composite parent) { + parent.setLayout(new GridLayout(1, false)); + + Composite c = new Composite(parent, SWT.NONE); + c.setLayout(new GridLayout(1, false)); + c.setLayoutData(new GridData(GridData.FILL_BOTH)); + + createControlsSection(c); + createDetailsSection(c); + + // Initialize widget state based on whether a client + // is selected or not. + clientSelected(); + + return c; + } + + private void createControlsSection(Composite parent) { + Composite c = new Composite(parent, SWT.NONE); + c.setLayout(new GridLayout(3, false)); + c.setLayoutData(new GridData(GridData.FILL_HORIZONTAL)); + + createGetHeapDataSection(c); + + Label l = new Label(c, SWT.SEPARATOR | SWT.VERTICAL); + l.setLayoutData(new GridData(GridData.FILL_VERTICAL)); + + createDisplaySection(c); + } + + private void createGetHeapDataSection(Composite parent) { + Composite c = new Composite(parent, SWT.NONE); + c.setLayout(new GridLayout(1, false)); + + createTakeHeapSnapshotButton(c); + + Label l = new Label(c, SWT.SEPARATOR | SWT.HORIZONTAL); + l.setLayoutData(new GridData(GridData.FILL_HORIZONTAL)); + + createLoadHeapDataButton(c); + } + + private void createTakeHeapSnapshotButton(Composite parent) { + mSnapshotHeapButton = new Button(parent, SWT.BORDER | SWT.PUSH); + mSnapshotHeapButton.setText(SNAPSHOT_HEAP_BUTTON_TEXT); + mSnapshotHeapButton.setLayoutData(new GridData()); + + // disable by default, enabled only when a client is selected + mSnapshotHeapButton.setEnabled(false); + + mSnapshotHeapButton.addSelectionListener(new SelectionAdapter() { + @Override + public void widgetSelected(SelectionEvent evt) { + snapshotHeap(); + } + }); + } + + private void snapshotHeap() { + Client c = getCurrentClient(); + assert c != null : "Snapshot Heap could not have been enabled w/o a selected client."; + + // send an async request + c.requestNativeHeapInformation(); + } + + private void createLoadHeapDataButton(Composite parent) { + mLoadHeapDataButton = new Button(parent, SWT.BORDER | SWT.PUSH); + mLoadHeapDataButton.setText(LOAD_HEAP_DATA_BUTTON_TEXT); + mLoadHeapDataButton.setLayoutData(new GridData()); + + // disable by default, enabled only when a client is selected + mLoadHeapDataButton.setEnabled(false); + + mLoadHeapDataButton.addSelectionListener(new SelectionAdapter() { + @Override + public void widgetSelected(SelectionEvent evt) { + loadHeapDataFromFile(); + } + }); + } + + private void loadHeapDataFromFile() { + // pop up a file dialog and get the file to load + final String path = getHeapDumpToImport(); + if (path == null) { + return; + } + + Reader reader = null; + try { + reader = new FileReader(path); + } catch (FileNotFoundException e) { + // cannot occur since user input was via a FileDialog + } + + Shell shell = Display.getDefault().getActiveShell(); + ProgressMonitorDialog d = new ProgressMonitorDialog(shell); + + NativeHeapDataImporter importer = new NativeHeapDataImporter(reader); + try { + d.run(true, true, importer); + } catch (InvocationTargetException e) { + // exception while parsing, display error to user and then return + MessageDialog.openError(shell, + "Error Importing Heap Data", + e.getCause().getMessage()); + return; + } catch (InterruptedException e) { + // operation cancelled by user, simply return + return; + } + + NativeHeapSnapshot snapshot = importer.getImportedSnapshot(); + + addToImportedSnapshots(snapshot); // save imported snapshot for future use + addNativeHeapSnapshot(snapshot); // add to currently displayed snapshots as well + + updateDisplay(); + } + + private void addToImportedSnapshots(NativeHeapSnapshot snapshot) { + Client c = getCurrentClient(); + + if (c == null) { + return; + } + + Integer pid = c.getClientData().getPid(); + List importedSnapshots = mImportedSnapshotsPerPid.get(pid); + if (importedSnapshots == null) { + importedSnapshots = new ArrayList(); + } + + importedSnapshots.add(snapshot); + mImportedSnapshotsPerPid.put(pid, importedSnapshots); + } + + private String getHeapDumpToImport() { + FileDialog fileDialog = new FileDialog(Display.getDefault().getActiveShell(), + SWT.OPEN); + + fileDialog.setText("Import Heap Dump"); + fileDialog.setFilterExtensions(new String[] {"*.txt"}); + fileDialog.setFilterPath(mPrefStore.getString(PREFS_LAST_IMPORTED_HEAPPATH)); + + String selectedFile = fileDialog.open(); + if (selectedFile != null) { + // save the path to restore in future dialog open + mPrefStore.setValue(PREFS_LAST_IMPORTED_HEAPPATH, new File(selectedFile).getParent()); + } + return selectedFile; + } + + private void createDisplaySection(Composite parent) { + Composite c = new Composite(parent, SWT.NONE); + c.setLayout(new GridLayout(2, false)); + c.setLayoutData(new GridData(GridData.FILL_HORIZONTAL)); + + // Create: Display: __________________ + createLabel(c, "Display:"); + mSnapshotIndexCombo = new Combo(c, SWT.NONE | SWT.READ_ONLY); + mSnapshotIndexCombo.setItems(new String[] {"No heap snapshots available."}); + mSnapshotIndexCombo.setEnabled(false); + mSnapshotIndexCombo.addSelectionListener(new SelectionAdapter() { + @Override + public void widgetSelected(SelectionEvent arg0) { + displaySelectedSnapshot(); + } + }); + + // Create: Memory Allocated (bytes): _________________ + createLabel(c, "Memory Allocated:"); + mMemoryAllocatedText = new Label(c, SWT.NONE); + GridData gd = new GridData(); + gd.widthHint = 100; + mMemoryAllocatedText.setLayoutData(gd); + + // Create: Search Path: __________________ + createLabel(c, SYMBOL_SEARCH_PATH_LABEL_TEXT); + mSymbolSearchPathText = new Text(c, SWT.BORDER); + mSymbolSearchPathText.setMessage(SYMBOL_SEARCH_PATH_TEXT_MESSAGE); + mSymbolSearchPathText.setToolTipText(SYMBOL_SEARCH_PATH_TOOLTIP_TEXT); + mSymbolSearchPathText.addModifyListener(new ModifyListener() { + @Override + public void modifyText(ModifyEvent arg0) { + String path = mSymbolSearchPathText.getText(); + updateSearchPath(path); + mPrefStore.setValue(PREFS_SYMBOL_SEARCH_PATH, path); + } + }); + mSymbolSearchPathText.setText(mPrefStore.getString(PREFS_SYMBOL_SEARCH_PATH)); + mSymbolSearchPathText.setLayoutData(new GridData(GridData.FILL_HORIZONTAL)); + } + + private void updateSearchPath(String path) { + Addr2Line.setSearchPath(path); + } + + private void createLabel(Composite parent, String text) { + Label l = new Label(parent, SWT.NONE); + l.setText(text); + GridData gd = new GridData(); + gd.horizontalAlignment = SWT.RIGHT; + l.setLayoutData(gd); + } + + /** + * Create the details section displaying the details table and the stack trace + * corresponding to the selection. + * + * The details is laid out like so: + * Details Toolbar + * Details Table + * ------------sash--- + * Stack Trace Label + * Stack Trace Text + * There is a sash in between the two sections, and we need to save/restore the sash + * preferences. Using FormLayout seems like the easiest solution here, but the layout + * code looks ugly as a result. + */ + private void createDetailsSection(Composite parent) { + final Composite c = new Composite(parent, SWT.NONE); + c.setLayout(new FormLayout()); + c.setLayoutData(new GridData(GridData.FILL_BOTH)); + + mDetailsToolBar = new ToolBar(c, SWT.FLAT | SWT.BORDER); + initializeDetailsToolBar(mDetailsToolBar); + + Tree detailsTree = new Tree(c, SWT.VIRTUAL | SWT.BORDER | SWT.MULTI); + initializeDetailsTree(detailsTree); + + final Sash sash = new Sash(c, SWT.HORIZONTAL | SWT.BORDER); + + Label stackTraceLabel = new Label(c, SWT.NONE); + stackTraceLabel.setText("Stack Trace:"); + + Tree stackTraceTree = new Tree(c, SWT.BORDER | SWT.MULTI); + initializeStackTraceTree(stackTraceTree); + + // layout the widgets created above + FormData data = new FormData(); + data.top = new FormAttachment(0, 0); + data.left = new FormAttachment(0, 0); + data.right = new FormAttachment(100, 0); + mDetailsToolBar.setLayoutData(data); + + data = new FormData(); + data.top = new FormAttachment(mDetailsToolBar, 0); + data.bottom = new FormAttachment(sash, 0); + data.left = new FormAttachment(0, 0); + data.right = new FormAttachment(100, 0); + detailsTree.setLayoutData(data); + + final FormData sashData = new FormData(); + sashData.top = new FormAttachment(mPrefStore.getInt(PREFS_SASH_HEIGHT_PERCENT), 0); + sashData.left = new FormAttachment(0, 0); + sashData.right = new FormAttachment(100, 0); + sash.setLayoutData(sashData); + + data = new FormData(); + data.top = new FormAttachment(sash, 0); + data.left = new FormAttachment(0, 0); + data.right = new FormAttachment(100, 0); + stackTraceLabel.setLayoutData(data); + + data = new FormData(); + data.top = new FormAttachment(stackTraceLabel, 0); + data.left = new FormAttachment(0, 0); + data.bottom = new FormAttachment(100, 0); + data.right = new FormAttachment(100, 0); + stackTraceTree.setLayoutData(data); + + sash.addListener(SWT.Selection, new Listener() { + @Override + public void handleEvent(Event e) { + Rectangle sashRect = sash.getBounds(); + Rectangle panelRect = c.getClientArea(); + int sashPercent = sashRect.y * 100 / panelRect.height; + mPrefStore.setValue(PREFS_SASH_HEIGHT_PERCENT, sashPercent); + + sashData.top = new FormAttachment(0, e.y); + c.layout(); + } + }); + } + + private void initializeDetailsToolBar(ToolBar toolbar) { + mGroupByButton = new ToolItem(toolbar, SWT.CHECK); + mGroupByButton.setImage(ImageLoader.getDdmUiLibLoader().loadImage(GROUPBY_IMAGE, + toolbar.getDisplay())); + mGroupByButton.setToolTipText(TOOLTIP_GROUPBY); + mGroupByButton.setSelection(mPrefStore.getBoolean(PREFS_GROUP_BY_LIBRARY)); + mGroupByButton.addSelectionListener(new SelectionAdapter() { + @Override + public void widgetSelected(SelectionEvent arg0) { + updateDisplayGrouping(); + } + }); + + mDiffsOnlyButton = new ToolItem(toolbar, SWT.CHECK); + mDiffsOnlyButton.setImage(ImageLoader.getDdmUiLibLoader().loadImage(DIFFS_ONLY_IMAGE, + toolbar.getDisplay())); + mDiffsOnlyButton.setToolTipText(TOOLTIP_DIFFS_ONLY); + mDiffsOnlyButton.setSelection(mPrefStore.getBoolean(PREFS_SHOW_DIFFS_ONLY)); + mDiffsOnlyButton.addSelectionListener(new SelectionAdapter() { + @Override + public void widgetSelected(SelectionEvent arg0) { + // simply refresh the display, as the display logic takes care of + // the current state of the diffs only checkbox. + int idx = mSnapshotIndexCombo.getSelectionIndex(); + displaySnapshot(idx); + } + }); + + mShowZygoteAllocationsButton = new ToolItem(toolbar, SWT.CHECK); + mShowZygoteAllocationsButton.setImage(ImageLoader.getDdmUiLibLoader().loadImage( + ZYGOTE_IMAGE, toolbar.getDisplay())); + mShowZygoteAllocationsButton.setToolTipText(TOOLTIP_ZYGOTE_ALLOCATIONS); + mShowZygoteAllocationsButton.setSelection( + mPrefStore.getBoolean(PREFS_SHOW_ZYGOTE_ALLOCATIONS)); + mShowZygoteAllocationsButton.addSelectionListener(new SelectionAdapter() { + @Override + public void widgetSelected(SelectionEvent arg0) { + updateDisplayForZygotes(); + } + }); + + mExportHeapDataButton = new ToolItem(toolbar, SWT.PUSH); + mExportHeapDataButton.setImage(ImageLoader.getDdmUiLibLoader().loadImage( + EXPORT_DATA_IMAGE, toolbar.getDisplay())); + mExportHeapDataButton.setToolTipText(TOOLTIP_EXPORT_DATA); + mExportHeapDataButton.addSelectionListener(new SelectionAdapter() { + @Override + public void widgetSelected(SelectionEvent arg0) { + exportSnapshot(); + } + }); + } + + /** Export currently displayed snapshot to a file */ + private void exportSnapshot() { + int idx = mSnapshotIndexCombo.getSelectionIndex(); + String snapshotName = mSnapshotIndexCombo.getItem(idx); + + FileDialog fileDialog = new FileDialog(Display.getDefault().getActiveShell(), + SWT.SAVE); + + fileDialog.setText("Save " + snapshotName); + fileDialog.setFileName("allocations.txt"); + + final String fileName = fileDialog.open(); + if (fileName == null) { + return; + } + + final NativeHeapSnapshot snapshot = mNativeHeapSnapshots.get(idx); + Thread t = new Thread(new Runnable() { + @Override + public void run() { + PrintWriter out; + try { + out = new PrintWriter(new BufferedWriter(new FileWriter(fileName))); + } catch (IOException e) { + displayErrorMessage(e.getMessage()); + return; + } + + for (NativeAllocationInfo alloc : snapshot.getAllocations()) { + out.println(alloc.toString()); + } + out.close(); + } + + private void displayErrorMessage(final String message) { + Display.getDefault().syncExec(new Runnable() { + @Override + public void run() { + MessageDialog.openError(Display.getDefault().getActiveShell(), + "Failed to export heap data", message); + } + }); + } + }); + t.setName("Saving Heap Data to File..."); + t.start(); + } + + private void initializeDetailsTree(Tree tree) { + tree.setHeaderVisible(true); + tree.setLinesVisible(true); + + List properties = Arrays.asList(new String[] { + "Library", + "Total", + "Percentage", + "Count", + "Size", + "Method", + }); + + List sampleValues = Arrays.asList(new String[] { + "/path/in/device/to/system/library.so", + "123456789", + " 100%", + "123456789", + "123456789", + "PossiblyLongDemangledMethodName", + }); + + // right align numeric values + List swtFlags = Arrays.asList(new Integer[] { + SWT.LEFT, + SWT.RIGHT, + SWT.RIGHT, + SWT.RIGHT, + SWT.RIGHT, + SWT.LEFT, + }); + + for (int i = 0; i < properties.size(); i++) { + String p = properties.get(i); + String v = sampleValues.get(i); + int flags = swtFlags.get(i); + TableHelper.createTreeColumn(tree, p, flags, v, getPref("details", p), mPrefStore); + } + + mDetailsTreeViewer = new TreeViewer(tree); + + mDetailsTreeViewer.setUseHashlookup(true); + + boolean displayZygotes = mPrefStore.getBoolean(PREFS_SHOW_ZYGOTE_ALLOCATIONS); + mContentProviderByAllocations = new NativeHeapProviderByAllocations(mDetailsTreeViewer, + displayZygotes); + mContentProviderByLibrary = new NativeHeapProviderByLibrary(mDetailsTreeViewer, + displayZygotes); + if (mPrefStore.getBoolean(PREFS_GROUP_BY_LIBRARY)) { + mDetailsTreeViewer.setContentProvider(mContentProviderByLibrary); + } else { + mDetailsTreeViewer.setContentProvider(mContentProviderByAllocations); + } + + mDetailsTreeLabelProvider = new NativeHeapLabelProvider(); + mDetailsTreeViewer.setLabelProvider(mDetailsTreeLabelProvider); + + mDetailsTreeViewer.setInput(null); + + tree.addSelectionListener(new SelectionAdapter() { + @Override + public void widgetSelected(SelectionEvent event) { + displayStackTraceForSelection(); + } + }); + } + + private void initializeStackTraceTree(Tree tree) { + tree.setHeaderVisible(true); + tree.setLinesVisible(true); + + List properties = Arrays.asList(new String[] { + "Address", + "Library", + "Method", + "File", + "Line", + }); + + List sampleValues = Arrays.asList(new String[] { + "0x1234_5678", + "/path/in/device/to/system/library.so", + "PossiblyLongDemangledMethodName", + "/android/out/prefix/in/home/directory/to/path/in/device/to/system/library.so", + "2000", + }); + + for (int i = 0; i < properties.size(); i++) { + String p = properties.get(i); + String v = sampleValues.get(i); + TableHelper.createTreeColumn(tree, p, SWT.LEFT, v, getPref("stack", p), mPrefStore); + } + + mStackTraceTreeViewer = new TreeViewer(tree); + + mStackTraceTreeViewer.setContentProvider(new NativeStackContentProvider()); + mStackTraceTreeViewer.setLabelProvider(new NativeStackLabelProvider()); + + mStackTraceTreeViewer.setInput(null); + } + + private void displayStackTraceForSelection() { + TreeItem []items = mDetailsTreeViewer.getTree().getSelection(); + if (items.length == 0) { + mStackTraceTreeViewer.setInput(null); + return; + } + + Object data = items[0].getData(); + if (!(data instanceof NativeAllocationInfo)) { + mStackTraceTreeViewer.setInput(null); + return; + } + + NativeAllocationInfo info = (NativeAllocationInfo) data; + if (info.isStackCallResolved()) { + mStackTraceTreeViewer.setInput(info.getResolvedStackCall()); + } else { + mStackTraceTreeViewer.setInput(info.getStackCallAddresses()); + } + } + + private String getPref(String prefix, String s) { + return "nativeheap.tree." + prefix + "." + s; + } + + @Override + public void setFocus() { + } + + private ITableFocusListener mTableFocusListener; + + @Override + public void setTableFocusListener(ITableFocusListener listener) { + mTableFocusListener = listener; + + final Tree heapSitesTree = mDetailsTreeViewer.getTree(); + final IFocusedTableActivator heapSitesActivator = new IFocusedTableActivator() { + @Override + public void copy(Clipboard clipboard) { + TreeItem[] items = heapSitesTree.getSelection(); + copyToClipboard(items, clipboard); + } + + @Override + public void selectAll() { + heapSitesTree.selectAll(); + } + }; + + heapSitesTree.addFocusListener(new FocusListener() { + @Override + public void focusLost(FocusEvent arg0) { + mTableFocusListener.focusLost(heapSitesActivator); + } + + @Override + public void focusGained(FocusEvent arg0) { + mTableFocusListener.focusGained(heapSitesActivator); + } + }); + + final Tree stackTraceTree = mStackTraceTreeViewer.getTree(); + final IFocusedTableActivator stackTraceActivator = new IFocusedTableActivator() { + @Override + public void copy(Clipboard clipboard) { + TreeItem[] items = stackTraceTree.getSelection(); + copyToClipboard(items, clipboard); + } + + @Override + public void selectAll() { + stackTraceTree.selectAll(); + } + }; + + stackTraceTree.addFocusListener(new FocusListener() { + @Override + public void focusLost(FocusEvent arg0) { + mTableFocusListener.focusLost(stackTraceActivator); + } + + @Override + public void focusGained(FocusEvent arg0) { + mTableFocusListener.focusGained(stackTraceActivator); + } + }); + } + + private void copyToClipboard(TreeItem[] items, Clipboard clipboard) { + StringBuilder sb = new StringBuilder(); + + for (TreeItem item : items) { + Object data = item.getData(); + if (data != null) { + sb.append(data.toString()); + sb.append('\n'); + } + } + + String content = sb.toString(); + if (content.length() > 0) { + clipboard.setContents( + new Object[] {sb.toString()}, + new Transfer[] {TextTransfer.getInstance()} + ); + } + } + + private class SymbolResolverTask implements Runnable { + private List mCallSites; + private List mMappedLibraries; + private Map mResolvedSymbolCache; + + public SymbolResolverTask(List callSites, + List mappedLibraries) { + mCallSites = callSites; + mMappedLibraries = mappedLibraries; + + mResolvedSymbolCache = new HashMap(); + } + + @Override + public void run() { + for (NativeAllocationInfo callSite : mCallSites) { + if (callSite.isStackCallResolved()) { + continue; + } + + List addresses = callSite.getStackCallAddresses(); + List resolvedStackInfo = + new ArrayList(addresses.size()); + + for (Long address : addresses) { + NativeStackCallInfo info = mResolvedSymbolCache.get(address); + + if (info != null) { + resolvedStackInfo.add(info); + } else { + info = resolveAddress(address); + resolvedStackInfo.add(info); + mResolvedSymbolCache.put(address, info); + } + } + + callSite.setResolvedStackCall(resolvedStackInfo); + } + + Display.getDefault().asyncExec(new Runnable() { + @Override + public void run() { + mDetailsTreeViewer.refresh(); + mStackTraceTreeViewer.refresh(); + } + }); + } + + private NativeStackCallInfo resolveAddress(long addr) { + NativeLibraryMapInfo library = getLibraryFor(addr); + + if (library != null) { + Addr2Line process = Addr2Line.getProcess(library); + if (process != null) { + NativeStackCallInfo info = process.getAddress(addr); + if (info != null) { + return info; + } + } + } + + return new NativeStackCallInfo(addr, + library != null ? library.getLibraryName() : null, + Long.toHexString(addr), + ""); + } + + private NativeLibraryMapInfo getLibraryFor(long addr) { + for (NativeLibraryMapInfo info : mMappedLibraries) { + if (info.isWithinLibrary(addr)) { + return info; + } + } + + Log.d("ddm-nativeheap", "Failed finding Library for " + Long.toHexString(addr)); + return null; + } + } +} diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/heap/NativeHeapProviderByAllocations.java b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/heap/NativeHeapProviderByAllocations.java new file mode 100644 index 00000000..c31716b9 --- /dev/null +++ b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/heap/NativeHeapProviderByAllocations.java @@ -0,0 +1,90 @@ +/* + * Copyright (C) 2011 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.ddmuilib.heap; + +import com.android.ddmlib.NativeAllocationInfo; + +import org.eclipse.jface.viewers.ILazyTreeContentProvider; +import org.eclipse.jface.viewers.TreeViewer; +import org.eclipse.jface.viewers.Viewer; + +import java.util.List; + +/** + * Content Provider for the native heap tree viewer in {@link NativeHeapPanel}. + * It expects a {@link NativeHeapSnapshot} as input, and provides the list of allocations + * in the heap dump as content to the UI. + */ +public final class NativeHeapProviderByAllocations implements ILazyTreeContentProvider { + private TreeViewer mViewer; + private boolean mDisplayZygoteMemory; + private NativeHeapSnapshot mNativeHeapDump; + + public NativeHeapProviderByAllocations(TreeViewer viewer, boolean displayZygotes) { + mViewer = viewer; + mDisplayZygoteMemory = displayZygotes; + } + + @Override + public void dispose() { + } + + @Override + public void inputChanged(Viewer viewer, Object oldInput, Object newInput) { + mNativeHeapDump = (NativeHeapSnapshot) newInput; + } + + @Override + public Object getParent(Object arg0) { + return null; + } + + @Override + public void updateChildCount(Object element, int currentChildCount) { + int childCount = 0; + + if (element == mNativeHeapDump) { // root element + childCount = getAllocations().size(); + } + + mViewer.setChildCount(element, childCount); + } + + @Override + public void updateElement(Object parent, int index) { + Object item = null; + + if (parent == mNativeHeapDump) { // root element + item = getAllocations().get(index); + } + + mViewer.replace(parent, index, item); + mViewer.setChildCount(item, 0); + } + + public void displayZygoteMemory(boolean en) { + mDisplayZygoteMemory = en; + } + + private List getAllocations() { + if (mDisplayZygoteMemory) { + return mNativeHeapDump.getAllocations(); + } else { + return mNativeHeapDump.getNonZygoteAllocations(); + } + } +} diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/heap/NativeHeapProviderByLibrary.java b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/heap/NativeHeapProviderByLibrary.java new file mode 100644 index 00000000..b786bfaa --- /dev/null +++ b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/heap/NativeHeapProviderByLibrary.java @@ -0,0 +1,92 @@ +/* + * Copyright (C) 2011 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.ddmuilib.heap; + +import org.eclipse.jface.viewers.ILazyTreeContentProvider; +import org.eclipse.jface.viewers.TreeViewer; +import org.eclipse.jface.viewers.Viewer; + +import java.util.List; + +/** + * Content Provider for the native heap tree viewer in {@link NativeHeapPanel}. + * It expects input of type {@link NativeHeapSnapshot}, and provides heap allocations + * grouped by library to the UI. + */ +public class NativeHeapProviderByLibrary implements ILazyTreeContentProvider { + private TreeViewer mViewer; + private boolean mDisplayZygoteMemory; + + public NativeHeapProviderByLibrary(TreeViewer viewer, boolean displayZygotes) { + mViewer = viewer; + mDisplayZygoteMemory = displayZygotes; + } + + @Override + public void dispose() { + } + + @Override + public void inputChanged(Viewer viewer, Object oldInput, Object newInput) { + } + + @Override + public Object getParent(Object element) { + return null; + } + + @Override + public void updateChildCount(Object element, int currentChildCount) { + int childCount = 0; + + if (element instanceof NativeHeapSnapshot) { + NativeHeapSnapshot snapshot = (NativeHeapSnapshot) element; + childCount = getLibraryAllocations(snapshot).size(); + } + + mViewer.setChildCount(element, childCount); + } + + @Override + public void updateElement(Object parent, int index) { + Object item = null; + int childCount = 0; + + if (parent instanceof NativeHeapSnapshot) { // root element + NativeHeapSnapshot snapshot = (NativeHeapSnapshot) parent; + item = getLibraryAllocations(snapshot).get(index); + childCount = ((NativeLibraryAllocationInfo) item).getAllocations().size(); + } else if (parent instanceof NativeLibraryAllocationInfo) { + item = ((NativeLibraryAllocationInfo) parent).getAllocations().get(index); + } + + mViewer.replace(parent, index, item); + mViewer.setChildCount(item, childCount); + } + + public void displayZygoteMemory(boolean en) { + mDisplayZygoteMemory = en; + } + + private List getLibraryAllocations(NativeHeapSnapshot snapshot) { + if (mDisplayZygoteMemory) { + return snapshot.getAllocationsByLibrary(); + } else { + return snapshot.getNonZygoteAllocationsByLibrary(); + } + } +} diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/heap/NativeHeapSnapshot.java b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/heap/NativeHeapSnapshot.java new file mode 100644 index 00000000..e2023d2c --- /dev/null +++ b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/heap/NativeHeapSnapshot.java @@ -0,0 +1,133 @@ +/* + * Copyright (C) 2011 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.ddmuilib.heap; + +import com.android.ddmlib.NativeAllocationInfo; + +import java.text.NumberFormat; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; + +/** + * A Native Heap Snapshot models a single heap dump. + * + * It primarily consists of a list of {@link NativeAllocationInfo} objects. From this list, + * other objects of interest to the UI are computed and cached for future use. + */ +public class NativeHeapSnapshot { + private static final NumberFormat NUMBER_FORMATTER = NumberFormat.getInstance(); + + private List mHeapAllocations; + private List mHeapAllocationsByLibrary; + + private List mNonZygoteHeapAllocations; + private List mNonZygoteHeapAllocationsByLibrary; + + private long mTotalSize; + + public NativeHeapSnapshot(List heapAllocations) { + mHeapAllocations = heapAllocations; + + // precompute the total size as this is always needed. + mTotalSize = getTotalMemory(heapAllocations); + } + + protected long getTotalMemory(Collection heapSnapshot) { + long total = 0; + + for (NativeAllocationInfo info : heapSnapshot) { + total += info.getAllocationCount() * info.getSize(); + } + + return total; + } + + public List getAllocations() { + return mHeapAllocations; + } + + public List getAllocationsByLibrary() { + if (mHeapAllocationsByLibrary != null) { + return mHeapAllocationsByLibrary; + } + + List heapAllocations = + NativeLibraryAllocationInfo.constructFrom(mHeapAllocations); + + // cache for future uses only if it is fully resolved. + if (isFullyResolved(heapAllocations)) { + mHeapAllocationsByLibrary = heapAllocations; + } + + return heapAllocations; + } + + private boolean isFullyResolved(List heapAllocations) { + for (NativeLibraryAllocationInfo info : heapAllocations) { + if (info.getLibraryName().equals(NativeLibraryAllocationInfo.UNRESOLVED_LIBRARY_NAME)) { + return false; + } + } + + return true; + } + + public long getTotalSize() { + return mTotalSize; + } + + public String getFormattedMemorySize() { + return String.format("%s bytes", formatMemorySize(getTotalSize())); + } + + protected String formatMemorySize(long memSize) { + return NUMBER_FORMATTER.format(memSize); + } + + public List getNonZygoteAllocations() { + if (mNonZygoteHeapAllocations != null) { + return mNonZygoteHeapAllocations; + } + + // filter out all zygote allocations + mNonZygoteHeapAllocations = new ArrayList(); + for (NativeAllocationInfo info : mHeapAllocations) { + if (info.isZygoteChild()) { + mNonZygoteHeapAllocations.add(info); + } + } + + return mNonZygoteHeapAllocations; + } + + public List getNonZygoteAllocationsByLibrary() { + if (mNonZygoteHeapAllocationsByLibrary != null) { + return mNonZygoteHeapAllocationsByLibrary; + } + + List heapAllocations = + NativeLibraryAllocationInfo.constructFrom(getNonZygoteAllocations()); + + // cache for future uses only if it is fully resolved. + if (isFullyResolved(heapAllocations)) { + mNonZygoteHeapAllocationsByLibrary = heapAllocations; + } + + return heapAllocations; + } +} diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/heap/NativeLibraryAllocationInfo.java b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/heap/NativeLibraryAllocationInfo.java new file mode 100644 index 00000000..1722cdb7 --- /dev/null +++ b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/heap/NativeLibraryAllocationInfo.java @@ -0,0 +1,135 @@ +/* + * Copyright (C) 2011 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.ddmuilib.heap; + +import com.android.ddmlib.NativeAllocationInfo; +import com.android.ddmlib.NativeStackCallInfo; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * A heap dump representation where each call site is associated with its source library. + */ +public final class NativeLibraryAllocationInfo { + /** Library name to use when grouping before symbol resolution is complete. */ + public static final String UNRESOLVED_LIBRARY_NAME = "Resolving.."; + + /** Any call site that cannot be resolved to a specific library goes under this name. */ + private static final String UNKNOWN_LIBRARY_NAME = "unknown"; + + private final String mLibraryName; + private final List mHeapAllocations; + private int mTotalSize; + + private NativeLibraryAllocationInfo(String libraryName) { + mLibraryName = libraryName; + mHeapAllocations = new ArrayList(); + } + + private void addAllocation(NativeAllocationInfo info) { + mHeapAllocations.add(info); + } + + private void updateTotalSize() { + mTotalSize = 0; + for (NativeAllocationInfo i : mHeapAllocations) { + mTotalSize += i.getAllocationCount() * i.getSize(); + } + } + + public String getLibraryName() { + return mLibraryName; + } + + public long getTotalSize() { + return mTotalSize; + } + + public List getAllocations() { + return mHeapAllocations; + } + + /** + * Factory method to create a list of {@link NativeLibraryAllocationInfo} objects, + * given the list of {@link NativeAllocationInfo} objects. + * + * If the {@link NativeAllocationInfo} objects do not have their symbols resolved, + * then they are grouped under the library {@link #UNRESOLVED_LIBRARY_NAME}. If they do + * have their symbols resolved, but map to an unknown library, then they are grouped under + * the library {@link #UNKNOWN_LIBRARY_NAME}. + */ + public static List constructFrom( + List allocations) { + if (allocations == null) { + return null; + } + + Map allocationsByLibrary = + new HashMap(); + + // go through each native allocation and assign it to the appropriate library + for (NativeAllocationInfo info : allocations) { + String libName = UNRESOLVED_LIBRARY_NAME; + + if (info.isStackCallResolved()) { + NativeStackCallInfo relevantStackCall = info.getRelevantStackCallInfo(); + if (relevantStackCall != null) { + libName = relevantStackCall.getLibraryName(); + } else { + libName = UNKNOWN_LIBRARY_NAME; + } + } + + addtoLibrary(allocationsByLibrary, libName, info); + } + + List libraryAllocations = + new ArrayList(allocationsByLibrary.values()); + + // now update some summary statistics for each library + for (NativeLibraryAllocationInfo l : libraryAllocations) { + l.updateTotalSize(); + } + + // finally, sort by total size + Collections.sort(libraryAllocations, new Comparator() { + @Override + public int compare(NativeLibraryAllocationInfo o1, + NativeLibraryAllocationInfo o2) { + return (int) (o2.getTotalSize() - o1.getTotalSize()); + } + }); + + return libraryAllocations; + } + + private static void addtoLibrary(Map libraryAllocations, + String libName, NativeAllocationInfo info) { + NativeLibraryAllocationInfo libAllocationInfo = libraryAllocations.get(libName); + if (libAllocationInfo == null) { + libAllocationInfo = new NativeLibraryAllocationInfo(libName); + libraryAllocations.put(libName, libAllocationInfo); + } + + libAllocationInfo.addAllocation(info); + } +} diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/heap/NativeStackContentProvider.java b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/heap/NativeStackContentProvider.java new file mode 100644 index 00000000..9a6ddb2a --- /dev/null +++ b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/heap/NativeStackContentProvider.java @@ -0,0 +1,56 @@ +/* + * Copyright (C) 2011 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.ddmuilib.heap; + +import org.eclipse.jface.viewers.ITreeContentProvider; +import org.eclipse.jface.viewers.Viewer; + +import java.util.List; + +public class NativeStackContentProvider implements ITreeContentProvider { + @Override + public Object[] getElements(Object arg0) { + return getChildren(arg0); + } + + @Override + public void dispose() { + } + + @Override + public void inputChanged(Viewer viewer, Object oldInput, Object newInput) { + } + + @Override + public Object[] getChildren(Object parentElement) { + if (parentElement instanceof List) { + return ((List) parentElement).toArray(); + } + + return null; + } + + @Override + public Object getParent(Object element) { + return null; + } + + @Override + public boolean hasChildren(Object element) { + return false; + } +} diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/heap/NativeStackLabelProvider.java b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/heap/NativeStackLabelProvider.java new file mode 100644 index 00000000..b7428b95 --- /dev/null +++ b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/heap/NativeStackLabelProvider.java @@ -0,0 +1,71 @@ +/* + * Copyright (C) 2011 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.ddmuilib.heap; + +import com.android.ddmlib.NativeStackCallInfo; + +import org.eclipse.jface.viewers.ITableLabelProvider; +import org.eclipse.jface.viewers.LabelProvider; +import org.eclipse.swt.graphics.Image; + +public class NativeStackLabelProvider extends LabelProvider implements ITableLabelProvider { + @Override + public Image getColumnImage(Object arg0, int arg1) { + return null; + } + + @Override + public String getColumnText(Object element, int index) { + if (element instanceof NativeStackCallInfo) { + return getResolvedStackTraceColumnText((NativeStackCallInfo) element, index); + } + + if (element instanceof Long) { + // if the addresses have not been resolved, then just display the + // addresses alone + return getStackAddressColumnText((Long) element, index); + } + + return null; + } + + public String getResolvedStackTraceColumnText(NativeStackCallInfo info, int index) { + switch (index) { + case 0: + return String.format("0x%08x", info.getAddress()); + case 1: + return info.getLibraryName(); + case 2: + return info.getMethodName(); + case 3: + return info.getSourceFile(); + case 4: + int l = info.getLineNumber(); + return l == -1 ? "" : Integer.toString(l); + } + + return null; + } + + private String getStackAddressColumnText(Long address, int index) { + if (index == 0) { + return String.format("0x%08x", address); + } + + return null; + } +} diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/heap/NativeSymbolResolverTask.java b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/heap/NativeSymbolResolverTask.java new file mode 100644 index 00000000..1a75c6e4 --- /dev/null +++ b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/heap/NativeSymbolResolverTask.java @@ -0,0 +1,306 @@ +/* + * Copyright (C) 2011 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.ddmuilib.heap; + +import com.android.ddmlib.NativeAllocationInfo; +import com.android.ddmlib.NativeLibraryMapInfo; +import com.android.ddmlib.NativeStackCallInfo; +import com.android.ddmuilib.DdmUiPreferences; + +import org.eclipse.core.runtime.IProgressMonitor; +import org.eclipse.jface.operation.IRunnableWithProgress; + +import java.io.BufferedReader; +import java.io.BufferedWriter; +import java.io.File; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.OutputStreamWriter; +import java.lang.reflect.InvocationTargetException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.SortedSet; +import java.util.TreeSet; + +/** + * A symbol resolver task that can resolve a set of addresses to their corresponding + * source method name + file name:line number. + * + * It first identifies the library that contains the address, and then runs addr2line on + * the library to get the symbol name + source location. + */ +public class NativeSymbolResolverTask implements IRunnableWithProgress { + private static final String ADDR2LINE; + private static final String DEFAULT_SYMBOLS_FOLDER; + + static { + String addr2lineEnv = System.getenv("ANDROID_ADDR2LINE"); + ADDR2LINE = addr2lineEnv != null ? addr2lineEnv : DdmUiPreferences.getAddr2Line(); + + String symbols = System.getenv("ANDROID_SYMBOLS"); + DEFAULT_SYMBOLS_FOLDER = symbols != null ? symbols : DdmUiPreferences.getSymbolDirectory(); + } + + private List mCallSites; + private List mMappedLibraries; + private List mSymbolSearchFolders; + + /** All unresolved addresses from all the callsites. */ + private SortedSet mUnresolvedAddresses; + + /** Set of all addresses that could were not resolved at the end of the resolution process. */ + private Set mUnresolvableAddresses; + + /** Map of library -> [unresolved addresses mapping to this library]. */ + private Map> mUnresolvedAddressesPerLibrary; + + /** Addresses that could not be mapped to a library, should be mostly empty. */ + private Set mUnmappedAddresses; + + /** Cache of the resolution for every unresolved address. */ + private Map mAddressResolution; + + /** List of libraries that were not located on disk. */ + private Set mNotFoundLibraries; + private String mAddr2LineErrorMessage = null; + + public NativeSymbolResolverTask(List callSites, + List mappedLibraries, + String symbolSearchPath) { + mCallSites = callSites; + mMappedLibraries = mappedLibraries; + mSymbolSearchFolders = new ArrayList(); + mSymbolSearchFolders.add(DEFAULT_SYMBOLS_FOLDER); + mSymbolSearchFolders.addAll(Arrays.asList(symbolSearchPath.split(":"))); + + mUnresolvedAddresses = new TreeSet(); + mUnresolvableAddresses = new HashSet(); + mUnresolvedAddressesPerLibrary = new HashMap>(); + mUnmappedAddresses = new HashSet(); + mAddressResolution = new HashMap(); + mNotFoundLibraries = new HashSet(); + } + + @Override + public void run(IProgressMonitor monitor) + throws InvocationTargetException, InterruptedException { + monitor.beginTask("Resolving symbols", IProgressMonitor.UNKNOWN); + + collectAllUnresolvedAddresses(); + checkCancellation(monitor); + + mapUnresolvedAddressesToLibrary(); + checkCancellation(monitor); + + resolveLibraryAddresses(monitor); + checkCancellation(monitor); + + resolveCallSites(mCallSites); + + monitor.done(); + } + + private void collectAllUnresolvedAddresses() { + for (NativeAllocationInfo callSite : mCallSites) { + mUnresolvedAddresses.addAll(callSite.getStackCallAddresses()); + } + } + + private void mapUnresolvedAddressesToLibrary() { + Set mappedAddresses = new HashSet(); + + for (NativeLibraryMapInfo lib : mMappedLibraries) { + SortedSet addressesInLibrary = mUnresolvedAddresses.subSet(lib.getStartAddress(), + lib.getEndAddress() + 1); + if (addressesInLibrary.size() > 0) { + mUnresolvedAddressesPerLibrary.put(lib, addressesInLibrary); + mappedAddresses.addAll(addressesInLibrary); + } + } + + // unmapped addresses = unresolved addresses - mapped addresses + mUnmappedAddresses.addAll(mUnresolvedAddresses); + mUnmappedAddresses.removeAll(mappedAddresses); + } + + private void resolveLibraryAddresses(IProgressMonitor monitor) throws InterruptedException { + for (NativeLibraryMapInfo lib : mUnresolvedAddressesPerLibrary.keySet()) { + String libPath = getLibraryLocation(lib); + Set addressesToResolve = mUnresolvedAddressesPerLibrary.get(lib); + + if (libPath == null) { + mNotFoundLibraries.add(lib.getLibraryName()); + markAddressesNotResolvable(addressesToResolve, lib); + } else { + monitor.subTask(String.format("Resolving addresses mapped to %s.", libPath)); + resolveAddresses(lib, libPath, addressesToResolve); + } + + checkCancellation(monitor); + } + } + + private void resolveAddresses(NativeLibraryMapInfo lib, String libPath, + Set addressesToResolve) { + Process addr2line = null; + try { + addr2line = new ProcessBuilder(ADDR2LINE, + "-C", // demangle + "-f", // display function names in addition to file:number + "-e", libPath).start(); + } catch (IOException e) { + // Since the library path is known to be valid, the only reason for an exception + // is that addr2line was not found. We just save the message in this case. + mAddr2LineErrorMessage = e.getMessage(); + markAddressesNotResolvable(addressesToResolve, lib); + return; + } + + BufferedReader resultReader = new BufferedReader(new InputStreamReader( + addr2line.getInputStream())); + BufferedWriter addressWriter = new BufferedWriter(new OutputStreamWriter( + addr2line.getOutputStream())); + + long libStartAddress = isExecutable(lib) ? 0 : lib.getStartAddress(); + try { + for (Long addr : addressesToResolve) { + long offset = addr.longValue() - libStartAddress; + addressWriter.write(Long.toHexString(offset)); + addressWriter.newLine(); + addressWriter.flush(); + String method = resultReader.readLine(); + String sourceFile = resultReader.readLine(); + + mAddressResolution.put(addr, + new NativeStackCallInfo(addr.longValue(), + lib.getLibraryName(), + method, + sourceFile)); + } + } catch (IOException e) { + // if there is any error, then mark the addresses not already resolved + // as unresolvable. + for (Long addr : addressesToResolve) { + if (mAddressResolution.get(addr) == null) { + markAddressNotResolvable(lib, addr); + } + } + } + + try { + resultReader.close(); + addressWriter.close(); + } catch (IOException e) { + // we can ignore these exceptions + } + + addr2line.destroy(); + } + + private boolean isExecutable(NativeLibraryMapInfo object) { + // TODO: Use a tool like readelf or nm to determine whether this object is a library + // or an executable. + // For now, we'll just assume that any object present in the bin folder is an executable. + String devicePath = object.getLibraryName(); + return devicePath.contains("/bin/"); + } + + private void markAddressesNotResolvable(Set addressesToResolve, + NativeLibraryMapInfo lib) { + for (Long addr : addressesToResolve) { + markAddressNotResolvable(lib, addr); + } + } + + private void markAddressNotResolvable(NativeLibraryMapInfo lib, Long addr) { + mAddressResolution.put(addr, + new NativeStackCallInfo(addr.longValue(), + lib.getLibraryName(), + Long.toHexString(addr), + "")); + mUnresolvableAddresses.add(addr); + } + + /** + * Locate on local disk the debug library w/ symbols corresponding to the + * library on the device. It searches for this library in the symbol path. + * @return absolute path if found, null otherwise + */ + private String getLibraryLocation(NativeLibraryMapInfo lib) { + String pathOnDevice = lib.getLibraryName(); + String libName = new File(pathOnDevice).getName(); + + for (String p : mSymbolSearchFolders) { + // try appending the full path on device + String fullPath = p + File.separator + pathOnDevice; + if (new File(fullPath).exists()) { + return fullPath; + } + + // try appending basename(library) + fullPath = p + File.separator + libName; + if (new File(fullPath).exists()) { + return fullPath; + } + } + + return null; + } + + private void resolveCallSites(List callSites) { + for (NativeAllocationInfo callSite : callSites) { + List stackInfo = new ArrayList(); + + for (Long addr : callSite.getStackCallAddresses()) { + NativeStackCallInfo info = mAddressResolution.get(addr); + + if (info != null) { + stackInfo.add(info); + } + } + + callSite.setResolvedStackCall(stackInfo); + } + } + + private void checkCancellation(IProgressMonitor monitor) throws InterruptedException { + if (monitor.isCanceled()) { + throw new InterruptedException(); + } + } + + public String getAddr2LineErrorMessage() { + return mAddr2LineErrorMessage; + } + + public Set getUnmappedAddresses() { + return mUnmappedAddresses; + } + + public Set getUnresolvableAddresses() { + return mUnresolvableAddresses; + } + + public Set getNotFoundLibraries() { + return mNotFoundLibraries; + } +} diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/location/CoordinateControls.java b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/location/CoordinateControls.java new file mode 100644 index 00000000..2aef53c2 --- /dev/null +++ b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/location/CoordinateControls.java @@ -0,0 +1,249 @@ +/* + * Copyright (C) 2008 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.ddmuilib.location; + +import org.eclipse.swt.SWT; +import org.eclipse.swt.events.ModifyEvent; +import org.eclipse.swt.events.ModifyListener; +import org.eclipse.swt.graphics.Point; +import org.eclipse.swt.layout.GridData; +import org.eclipse.swt.layout.GridLayout; +import org.eclipse.swt.widgets.Composite; +import org.eclipse.swt.widgets.Text; + +import java.text.DecimalFormat; +import java.text.ParseException; + +/** + * Encapsulation of controls handling a location coordinate in decimal and sexagesimal. + *

This handle the conversion between both modes automatically by using a {@link ModifyListener} + * on all the {@link Text} widgets. + *

To get/set the coordinate, use {@link #setValue(double)} and {@link #getValue()} (preceded by + * a call to {@link #isValueValid()}) + */ +public final class CoordinateControls { + private double mValue; + private boolean mValueValidity = false; + private Text mDecimalText; + private Text mSexagesimalDegreeText; + private Text mSexagesimalMinuteText; + private Text mSexagesimalSecondText; + private final DecimalFormat mDecimalFormat = new DecimalFormat(); + + /** Internal flag to prevent {@link ModifyEvent} to be sent when {@link Text#setText(String)} + * is called. This is an int instead of a boolean to act as a counter. */ + private int mManualTextChange = 0; + + /** + * ModifyListener for the 3 {@link Text} controls of the sexagesimal mode. + */ + private ModifyListener mSexagesimalListener = new ModifyListener() { + @Override + public void modifyText(ModifyEvent event) { + if (mManualTextChange > 0) { + return; + } + try { + mValue = getValueFromSexagesimalControls(); + setValueIntoDecimalControl(mValue); + mValueValidity = true; + } catch (ParseException e) { + // wrong format empty the decimal controls. + mValueValidity = false; + resetDecimalControls(); + } + } + }; + + /** + * Creates the {@link Text} control for the decimal display of the coordinate. + *

The control is expected to be placed in a Composite using a {@link GridLayout}. + * @param parent The {@link Composite} parent of the control. + */ + public void createDecimalText(Composite parent) { + mDecimalText = createTextControl(parent, "-199.999999", new ModifyListener() { + @Override + public void modifyText(ModifyEvent event) { + if (mManualTextChange > 0) { + return; + } + try { + mValue = mDecimalFormat.parse(mDecimalText.getText()).doubleValue(); + setValueIntoSexagesimalControl(mValue); + mValueValidity = true; + } catch (ParseException e) { + // wrong format empty the sexagesimal controls. + mValueValidity = false; + resetSexagesimalControls(); + } + } + }); + } + + /** + * Creates the {@link Text} control for the "degree" display of the coordinate in sexagesimal + * mode. + *

The control is expected to be placed in a Composite using a {@link GridLayout}. + * @param parent The {@link Composite} parent of the control. + */ + public void createSexagesimalDegreeText(Composite parent) { + mSexagesimalDegreeText = createTextControl(parent, "-199", mSexagesimalListener); //$NON-NLS-1$ + } + + /** + * Creates the {@link Text} control for the "minute" display of the coordinate in sexagesimal + * mode. + *

The control is expected to be placed in a Composite using a {@link GridLayout}. + * @param parent The {@link Composite} parent of the control. + */ + public void createSexagesimalMinuteText(Composite parent) { + mSexagesimalMinuteText = createTextControl(parent, "99", mSexagesimalListener); //$NON-NLS-1$ + } + + /** + * Creates the {@link Text} control for the "second" display of the coordinate in sexagesimal + * mode. + *

The control is expected to be placed in a Composite using a {@link GridLayout}. + * @param parent The {@link Composite} parent of the control. + */ + public void createSexagesimalSecondText(Composite parent) { + mSexagesimalSecondText = createTextControl(parent, "99.999", mSexagesimalListener); //$NON-NLS-1$ + } + + /** + * Sets the coordinate into the {@link Text} controls. + * @param value the coordinate value to set. + */ + public void setValue(double value) { + mValue = value; + mValueValidity = true; + setValueIntoDecimalControl(value); + setValueIntoSexagesimalControl(value); + } + + /** + * Returns whether the value in the control(s) is valid. + */ + public boolean isValueValid() { + return mValueValidity; + } + + /** + * Returns the current value set in the control(s). + *

This value can be erroneous, and a check with {@link #isValueValid()} should be performed + * before any call to this method. + */ + public double getValue() { + return mValue; + } + + /** + * Enables or disables all the {@link Text} controls. + * @param enabled the enabled state. + */ + public void setEnabled(boolean enabled) { + mDecimalText.setEnabled(enabled); + mSexagesimalDegreeText.setEnabled(enabled); + mSexagesimalMinuteText.setEnabled(enabled); + mSexagesimalSecondText.setEnabled(enabled); + } + + private void resetDecimalControls() { + mManualTextChange++; + mDecimalText.setText(""); //$NON-NLS-1$ + mManualTextChange--; + } + + private void resetSexagesimalControls() { + mManualTextChange++; + mSexagesimalDegreeText.setText(""); //$NON-NLS-1$ + mSexagesimalMinuteText.setText(""); //$NON-NLS-1$ + mSexagesimalSecondText.setText(""); //$NON-NLS-1$ + mManualTextChange--; + } + + /** + * Creates a {@link Text} with a given parent, default string and a {@link ModifyListener} + * @param parent the parent {@link Composite}. + * @param defaultString the default string to be used to compute the {@link Text} control + * size hint. + * @param listener the {@link ModifyListener} to be called when the {@link Text} control is + * modified. + */ + private Text createTextControl(Composite parent, String defaultString, + ModifyListener listener) { + // create the control + Text text = new Text(parent, SWT.BORDER | SWT.LEFT | SWT.SINGLE); + + // add the standard listener to it. + text.addModifyListener(listener); + + // compute its size/ + mManualTextChange++; + text.setText(defaultString); + text.pack(); + Point size = text.computeSize(SWT.DEFAULT, SWT.DEFAULT); + text.setText(""); //$NON-NLS-1$ + mManualTextChange--; + + GridData gridData = new GridData(); + gridData.widthHint = size.x; + text.setLayoutData(gridData); + + return text; + } + + private double getValueFromSexagesimalControls() throws ParseException { + double degrees = mDecimalFormat.parse(mSexagesimalDegreeText.getText()).doubleValue(); + double minutes = mDecimalFormat.parse(mSexagesimalMinuteText.getText()).doubleValue(); + double seconds = mDecimalFormat.parse(mSexagesimalSecondText.getText()).doubleValue(); + + boolean isPositive = (degrees >= 0.); + degrees = Math.abs(degrees); + + double value = degrees + minutes / 60. + seconds / 3600.; + return isPositive ? value : - value; + } + + private void setValueIntoDecimalControl(double value) { + mManualTextChange++; + mDecimalText.setText(String.format("%.6f", value)); + mManualTextChange--; + } + + private void setValueIntoSexagesimalControl(double value) { + // get the sign and make the number positive no matter what. + boolean isPositive = (value >= 0.); + value = Math.abs(value); + + // get the degree + double degrees = Math.floor(value); + + // get the minutes + double minutes = Math.floor((value - degrees) * 60.); + + // get the seconds. + double seconds = (value - degrees) * 3600. - minutes * 60.; + + mManualTextChange++; + mSexagesimalDegreeText.setText( + Integer.toString(isPositive ? (int)degrees : (int)- degrees)); + mSexagesimalMinuteText.setText(Integer.toString((int)minutes)); + mSexagesimalSecondText.setText(String.format("%.3f", seconds)); //$NON-NLS-1$ + mManualTextChange--; + } +} diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/location/GpxParser.java b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/location/GpxParser.java new file mode 100644 index 00000000..a30337a7 --- /dev/null +++ b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/location/GpxParser.java @@ -0,0 +1,373 @@ +/* + * Copyright (C) 2008 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.ddmuilib.location; + +import org.xml.sax.Attributes; +import org.xml.sax.InputSource; +import org.xml.sax.SAXException; +import org.xml.sax.SAXParseException; +import org.xml.sax.helpers.DefaultHandler; + +import java.io.FileReader; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Calendar; +import java.util.List; +import java.util.TimeZone; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import javax.xml.parsers.ParserConfigurationException; +import javax.xml.parsers.SAXParser; +import javax.xml.parsers.SAXParserFactory; + +/** + * A very basic GPX parser to meet the need of the emulator control panel. + *

+ * It parses basic waypoint information, and tracks (merging segments). + */ +public class GpxParser { + + private final static String NS_GPX = "http://www.topografix.com/GPX/1/1"; //$NON-NLS-1$ + + private final static String NODE_WAYPOINT = "wpt"; //$NON-NLS-1$ + private final static String NODE_TRACK = "trk"; //$NON-NLS-1$ + private final static String NODE_TRACK_SEGMENT = "trkseg"; //$NON-NLS-1$ + private final static String NODE_TRACK_POINT = "trkpt"; //$NON-NLS-1$ + private final static String NODE_NAME = "name"; //$NON-NLS-1$ + private final static String NODE_TIME = "time"; //$NON-NLS-1$ + private final static String NODE_ELEVATION = "ele"; //$NON-NLS-1$ + private final static String NODE_DESCRIPTION = "desc"; //$NON-NLS-1$ + private final static String ATTR_LONGITUDE = "lon"; //$NON-NLS-1$ + private final static String ATTR_LATITUDE = "lat"; //$NON-NLS-1$ + + private static SAXParserFactory sParserFactory; + + static { + sParserFactory = SAXParserFactory.newInstance(); + sParserFactory.setNamespaceAware(true); + } + + private String mFileName; + + private GpxHandler mHandler; + + /** Pattern to parse time with optional sub-second precision, and optional + * Z indicating the time is in UTC. */ + private final static Pattern ISO8601_TIME = + Pattern.compile("(\\d{4})-(\\d\\d)-(\\d\\d)T(\\d\\d):(\\d\\d):(\\d\\d)(?:(\\.\\d+))?(Z)?"); //$NON-NLS-1$ + + /** + * Handler for the SAX parser. + */ + private static class GpxHandler extends DefaultHandler { + // --------- parsed data --------- + List mWayPoints; + List mTrackList; + + // --------- state for parsing --------- + Track mCurrentTrack; + TrackPoint mCurrentTrackPoint; + WayPoint mCurrentWayPoint; + final StringBuilder mStringAccumulator = new StringBuilder(); + + boolean mSuccess = true; + + @Override + public void startElement(String uri, String localName, String name, Attributes attributes) + throws SAXException { + // we only care about the standard GPX nodes. + try { + if (NS_GPX.equals(uri)) { + if (NODE_WAYPOINT.equals(localName)) { + if (mWayPoints == null) { + mWayPoints = new ArrayList(); + } + + mWayPoints.add(mCurrentWayPoint = new WayPoint()); + handleLocation(mCurrentWayPoint, attributes); + } else if (NODE_TRACK.equals(localName)) { + if (mTrackList == null) { + mTrackList = new ArrayList(); + } + + mTrackList.add(mCurrentTrack = new Track()); + } else if (NODE_TRACK_SEGMENT.equals(localName)) { + // for now we do nothing here. This will merge all the segments into + // a single TrackPoint list in the Track. + } else if (NODE_TRACK_POINT.equals(localName)) { + if (mCurrentTrack != null) { + mCurrentTrack.addPoint(mCurrentTrackPoint = new TrackPoint()); + handleLocation(mCurrentTrackPoint, attributes); + } + } + } + } finally { + // no matter the node, we empty the StringBuilder accumulator when we start + // a new node. + mStringAccumulator.setLength(0); + } + } + + /** + * Processes new characters for the node content. The characters are simply stored, + * and will be processed when {@link #endElement(String, String, String)} is called. + */ + @Override + public void characters(char[] ch, int start, int length) throws SAXException { + mStringAccumulator.append(ch, start, length); + } + + @Override + public void endElement(String uri, String localName, String name) throws SAXException { + if (NS_GPX.equals(uri)) { + if (NODE_WAYPOINT.equals(localName)) { + mCurrentWayPoint = null; + } else if (NODE_TRACK.equals(localName)) { + mCurrentTrack = null; + } else if (NODE_TRACK_POINT.equals(localName)) { + mCurrentTrackPoint = null; + } else if (NODE_NAME.equals(localName)) { + if (mCurrentTrack != null) { + mCurrentTrack.setName(mStringAccumulator.toString()); + } else if (mCurrentWayPoint != null) { + mCurrentWayPoint.setName(mStringAccumulator.toString()); + } + } else if (NODE_TIME.equals(localName)) { + if (mCurrentTrackPoint != null) { + mCurrentTrackPoint.setTime(computeTime(mStringAccumulator.toString())); + } + } else if (NODE_ELEVATION.equals(localName)) { + if (mCurrentTrackPoint != null) { + mCurrentTrackPoint.setElevation( + Double.parseDouble(mStringAccumulator.toString())); + } else if (mCurrentWayPoint != null) { + mCurrentWayPoint.setElevation( + Double.parseDouble(mStringAccumulator.toString())); + } + } else if (NODE_DESCRIPTION.equals(localName)) { + if (mCurrentWayPoint != null) { + mCurrentWayPoint.setDescription(mStringAccumulator.toString()); + } + } + } + } + + @Override + public void error(SAXParseException e) throws SAXException { + mSuccess = false; + } + + @Override + public void fatalError(SAXParseException e) throws SAXException { + mSuccess = false; + } + + /** + * Converts the string description of the time into milliseconds since epoch. + * @param timeString the string data. + * @return date in milliseconds. + */ + private long computeTime(String timeString) { + // Time looks like: 2008-04-05T19:24:50Z + Matcher m = ISO8601_TIME.matcher(timeString); + if (m.matches()) { + // get the various elements and reconstruct time as a long. + try { + int year = Integer.parseInt(m.group(1)); + int month = Integer.parseInt(m.group(2)); + int date = Integer.parseInt(m.group(3)); + int hourOfDay = Integer.parseInt(m.group(4)); + int minute = Integer.parseInt(m.group(5)); + int second = Integer.parseInt(m.group(6)); + + // handle the optional parameters. + int milliseconds = 0; + + String subSecondGroup = m.group(7); + if (subSecondGroup != null) { + milliseconds = (int)(1000 * Double.parseDouble(subSecondGroup)); + } + + boolean utcTime = m.group(8) != null; + + // now we convert into milliseconds since epoch. + Calendar c; + if (utcTime) { + c = Calendar.getInstance(TimeZone.getTimeZone("GMT")); //$NON-NLS-1$ + } else { + c = Calendar.getInstance(); + } + + c.set(year, month, date, hourOfDay, minute, second); + + return c.getTimeInMillis() + milliseconds; + } catch (NumberFormatException e) { + // format is invalid, we'll return -1 below. + } + + } + + // invalid time! + return -1; + } + + /** + * Handles the location attributes and store them into a {@link LocationPoint}. + * @param locationNode the {@link LocationPoint} to receive the location data. + * @param attributes the attributes from the XML node. + */ + private void handleLocation(LocationPoint locationNode, Attributes attributes) { + try { + double longitude = Double.parseDouble(attributes.getValue(ATTR_LONGITUDE)); + double latitude = Double.parseDouble(attributes.getValue(ATTR_LATITUDE)); + + locationNode.setLocation(longitude, latitude); + } catch (NumberFormatException e) { + // wrong data, do nothing. + } + } + + WayPoint[] getWayPoints() { + if (mWayPoints != null) { + return mWayPoints.toArray(new WayPoint[mWayPoints.size()]); + } + + return null; + } + + Track[] getTracks() { + if (mTrackList != null) { + return mTrackList.toArray(new Track[mTrackList.size()]); + } + + return null; + } + + boolean getSuccess() { + return mSuccess; + } + } + + /** + * A GPS track. + *

A track is composed of a list of {@link TrackPoint} and optional name and comment. + */ + public final static class Track { + private String mName; + private String mComment; + private List mPoints = new ArrayList(); + + void setName(String name) { + mName = name; + } + + public String getName() { + return mName; + } + + void setComment(String comment) { + mComment = comment; + } + + public String getComment() { + return mComment; + } + + void addPoint(TrackPoint trackPoint) { + mPoints.add(trackPoint); + } + + public TrackPoint[] getPoints() { + return mPoints.toArray(new TrackPoint[mPoints.size()]); + } + + public long getFirstPointTime() { + if (mPoints.size() > 0) { + return mPoints.get(0).getTime(); + } + + return -1; + } + + public long getLastPointTime() { + if (mPoints.size() > 0) { + return mPoints.get(mPoints.size()-1).getTime(); + } + + return -1; + } + + public int getPointCount() { + return mPoints.size(); + } + } + + /** + * Creates a new GPX parser for a file specified by its full path. + * @param fileName The full path of the GPX file to parse. + */ + public GpxParser(String fileName) { + mFileName = fileName; + } + + /** + * Parses the GPX file. + * @return true if success. + */ + public boolean parse() { + try { + SAXParser parser = sParserFactory.newSAXParser(); + + mHandler = new GpxHandler(); + + parser.parse(new InputSource(new FileReader(mFileName)), mHandler); + + return mHandler.getSuccess(); + } catch (ParserConfigurationException e) { + } catch (SAXException e) { + } catch (IOException e) { + } finally { + } + + return false; + } + + /** + * Returns the parsed {@link WayPoint} objects, or null if none were found (or + * if the parsing failed. + */ + public WayPoint[] getWayPoints() { + if (mHandler != null) { + return mHandler.getWayPoints(); + } + + return null; + } + + /** + * Returns the parsed {@link Track} objects, or null if none were found (or + * if the parsing failed. + */ + public Track[] getTracks() { + if (mHandler != null) { + return mHandler.getTracks(); + } + + return null; + } +} diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/location/KmlParser.java b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/location/KmlParser.java new file mode 100644 index 00000000..af485ac1 --- /dev/null +++ b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/location/KmlParser.java @@ -0,0 +1,210 @@ +/* + * Copyright (C) 2008 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.ddmuilib.location; + +import org.xml.sax.Attributes; +import org.xml.sax.InputSource; +import org.xml.sax.SAXException; +import org.xml.sax.SAXParseException; +import org.xml.sax.helpers.DefaultHandler; + +import java.io.FileReader; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import javax.xml.parsers.ParserConfigurationException; +import javax.xml.parsers.SAXParser; +import javax.xml.parsers.SAXParserFactory; + +/** + * A very basic KML parser to meet the need of the emulator control panel. + *

+ * It parses basic Placemark information. + */ +public class KmlParser { + + private final static String NS_KML_2 = "http://earth.google.com/kml/2."; //$NON-NLS-1$ + + private final static String NODE_PLACEMARK = "Placemark"; //$NON-NLS-1$ + private final static String NODE_NAME = "name"; //$NON-NLS-1$ + private final static String NODE_COORDINATES = "coordinates"; //$NON-NLS-1$ + + private final static Pattern sLocationPattern = Pattern.compile("([^,]+),([^,]+)(?:,([^,]+))?"); + + private static SAXParserFactory sParserFactory; + + static { + sParserFactory = SAXParserFactory.newInstance(); + sParserFactory.setNamespaceAware(true); + } + + private String mFileName; + + private KmlHandler mHandler; + + /** + * Handler for the SAX parser. + */ + private static class KmlHandler extends DefaultHandler { + // --------- parsed data --------- + List mWayPoints; + + // --------- state for parsing --------- + WayPoint mCurrentWayPoint; + final StringBuilder mStringAccumulator = new StringBuilder(); + + boolean mSuccess = true; + + @Override + public void startElement(String uri, String localName, String name, Attributes attributes) + throws SAXException { + // we only care about the standard GPX nodes. + try { + if (uri.startsWith(NS_KML_2)) { + if (NODE_PLACEMARK.equals(localName)) { + if (mWayPoints == null) { + mWayPoints = new ArrayList(); + } + + mWayPoints.add(mCurrentWayPoint = new WayPoint()); + } + } + } finally { + // no matter the node, we empty the StringBuilder accumulator when we start + // a new node. + mStringAccumulator.setLength(0); + } + } + + /** + * Processes new characters for the node content. The characters are simply stored, + * and will be processed when {@link #endElement(String, String, String)} is called. + */ + @Override + public void characters(char[] ch, int start, int length) throws SAXException { + mStringAccumulator.append(ch, start, length); + } + + @Override + public void endElement(String uri, String localName, String name) throws SAXException { + if (uri.startsWith(NS_KML_2)) { + if (NODE_PLACEMARK.equals(localName)) { + mCurrentWayPoint = null; + } else if (NODE_NAME.equals(localName)) { + if (mCurrentWayPoint != null) { + mCurrentWayPoint.setName(mStringAccumulator.toString()); + } + } else if (NODE_COORDINATES.equals(localName)) { + if (mCurrentWayPoint != null) { + parseLocation(mCurrentWayPoint, mStringAccumulator.toString()); + } + } + } + } + + @Override + public void error(SAXParseException e) throws SAXException { + mSuccess = false; + } + + @Override + public void fatalError(SAXParseException e) throws SAXException { + mSuccess = false; + } + + /** + * Parses the location string and store the information into a {@link LocationPoint}. + * @param locationNode the {@link LocationPoint} to receive the location data. + * @param location The string containing the location info. + */ + private void parseLocation(LocationPoint locationNode, String location) { + Matcher m = sLocationPattern.matcher(location); + if (m.matches()) { + try { + double longitude = Double.parseDouble(m.group(1)); + double latitude = Double.parseDouble(m.group(2)); + + locationNode.setLocation(longitude, latitude); + + if (m.groupCount() == 3) { + // looks like we have elevation data. + locationNode.setElevation(Double.parseDouble(m.group(3))); + } + } catch (NumberFormatException e) { + // wrong data, do nothing. + } + } + } + + WayPoint[] getWayPoints() { + if (mWayPoints != null) { + return mWayPoints.toArray(new WayPoint[mWayPoints.size()]); + } + + return null; + } + + boolean getSuccess() { + return mSuccess; + } + } + + /** + * Creates a new GPX parser for a file specified by its full path. + * @param fileName The full path of the GPX file to parse. + */ + public KmlParser(String fileName) { + mFileName = fileName; + } + + /** + * Parses the GPX file. + * @return true if success. + */ + public boolean parse() { + try { + SAXParser parser = sParserFactory.newSAXParser(); + + mHandler = new KmlHandler(); + + parser.parse(new InputSource(new FileReader(mFileName)), mHandler); + + return mHandler.getSuccess(); + } catch (ParserConfigurationException e) { + } catch (SAXException e) { + } catch (IOException e) { + } finally { + } + + return false; + } + + /** + * Returns the parsed {@link WayPoint} objects, or null if none were found (or + * if the parsing failed. + */ + public WayPoint[] getWayPoints() { + if (mHandler != null) { + return mHandler.getWayPoints(); + } + + return null; + } +} diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/location/LocationPoint.java b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/location/LocationPoint.java new file mode 100644 index 00000000..dbb8f417 --- /dev/null +++ b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/location/LocationPoint.java @@ -0,0 +1,53 @@ +/* + * Copyright (C) 2008 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.ddmuilib.location; + +/** + * Base class for Location aware points. + */ +class LocationPoint { + private double mLongitude; + private double mLatitude; + private boolean mHasElevation = false; + private double mElevation; + + final void setLocation(double longitude, double latitude) { + mLongitude = longitude; + mLatitude = latitude; + } + + public final double getLongitude() { + return mLongitude; + } + + public final double getLatitude() { + return mLatitude; + } + + final void setElevation(double elevation) { + mElevation = elevation; + mHasElevation = true; + } + + public final boolean hasElevation() { + return mHasElevation; + } + + public final double getElevation() { + return mElevation; + } +} diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/location/TrackContentProvider.java b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/location/TrackContentProvider.java new file mode 100644 index 00000000..da21920c --- /dev/null +++ b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/location/TrackContentProvider.java @@ -0,0 +1,48 @@ +/* + * Copyright (C) 2008 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.ddmuilib.location; + +import com.android.ddmuilib.location.GpxParser.Track; + +import org.eclipse.jface.viewers.IStructuredContentProvider; +import org.eclipse.jface.viewers.Viewer; + +/** + * Content provider to display {@link Track} objects in a Table. + *

The expected type for the input is {@link Track}[]. + */ +public class TrackContentProvider implements IStructuredContentProvider { + + @Override + public Object[] getElements(Object inputElement) { + if (inputElement instanceof Track[]) { + return (Track[])inputElement; + } + + return new Object[0]; + } + + @Override + public void dispose() { + // pass + } + + @Override + public void inputChanged(Viewer viewer, Object oldInput, Object newInput) { + // pass + } +} diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/location/TrackLabelProvider.java b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/location/TrackLabelProvider.java new file mode 100644 index 00000000..50acb538 --- /dev/null +++ b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/location/TrackLabelProvider.java @@ -0,0 +1,87 @@ +/* + * Copyright (C) 2008 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.ddmuilib.location; + +import com.android.ddmuilib.location.GpxParser.Track; + +import org.eclipse.jface.viewers.ILabelProviderListener; +import org.eclipse.jface.viewers.ITableLabelProvider; +import org.eclipse.swt.graphics.Image; +import org.eclipse.swt.widgets.Table; + +import java.util.Date; + +/** + * Label Provider for {@link Table} objects displaying {@link Track} objects. + */ +public class TrackLabelProvider implements ITableLabelProvider { + + @Override + public Image getColumnImage(Object element, int columnIndex) { + return null; + } + + @Override + public String getColumnText(Object element, int columnIndex) { + if (element instanceof Track) { + Track track = (Track)element; + switch (columnIndex) { + case 0: + return track.getName(); + case 1: + return Integer.toString(track.getPointCount()); + case 2: + long time = track.getFirstPointTime(); + if (time != -1) { + return new Date(time).toString(); + } + break; + case 3: + time = track.getLastPointTime(); + if (time != -1) { + return new Date(time).toString(); + } + break; + case 4: + return track.getComment(); + } + } + + return null; + } + + @Override + public void addListener(ILabelProviderListener listener) { + // pass + } + + @Override + public void dispose() { + // pass + } + + @Override + public boolean isLabelProperty(Object element, String property) { + // pass + return false; + } + + @Override + public void removeListener(ILabelProviderListener listener) { + // pass + } +} diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/location/TrackPoint.java b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/location/TrackPoint.java new file mode 100644 index 00000000..527f4bf9 --- /dev/null +++ b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/location/TrackPoint.java @@ -0,0 +1,34 @@ +/* + * Copyright (C) 2008 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.ddmuilib.location; + + +/** + * A Track Point. + *

A track point is a point in time and space. + */ +public class TrackPoint extends LocationPoint { + private long mTime; + + void setTime(long time) { + mTime = time; + } + + public long getTime() { + return mTime; + } +} diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/location/WayPoint.java b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/location/WayPoint.java new file mode 100644 index 00000000..32880bde --- /dev/null +++ b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/location/WayPoint.java @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2008 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.ddmuilib.location; + +/** + * A GPS/KML way point. + *

A waypoint is a user specified location, with a name and an optional description. + */ +public final class WayPoint extends LocationPoint { + private String mName; + private String mDescription; + + void setName(String name) { + mName = name; + } + + public String getName() { + return mName; + } + + void setDescription(String description) { + mDescription = description; + } + + public String getDescription() { + return mDescription; + } +} diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/location/WayPointContentProvider.java b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/location/WayPointContentProvider.java new file mode 100644 index 00000000..1b7fe153 --- /dev/null +++ b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/location/WayPointContentProvider.java @@ -0,0 +1,46 @@ +/* + * Copyright (C) 2008 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.ddmuilib.location; + +import org.eclipse.jface.viewers.IStructuredContentProvider; +import org.eclipse.jface.viewers.Viewer; + +/** + * Content provider to display {@link WayPoint} objects in a Table. + *

The expected type for the input is {@link WayPoint}[]. + */ +public class WayPointContentProvider implements IStructuredContentProvider { + + @Override + public Object[] getElements(Object inputElement) { + if (inputElement instanceof WayPoint[]) { + return (WayPoint[])inputElement; + } + + return new Object[0]; + } + + @Override + public void dispose() { + // pass + } + + @Override + public void inputChanged(Viewer viewer, Object oldInput, Object newInput) { + // pass + } +} diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/location/WayPointLabelProvider.java b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/location/WayPointLabelProvider.java new file mode 100644 index 00000000..9f642f17 --- /dev/null +++ b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/location/WayPointLabelProvider.java @@ -0,0 +1,79 @@ +/* + * Copyright (C) 2008 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.ddmuilib.location; + +import org.eclipse.jface.viewers.ILabelProviderListener; +import org.eclipse.jface.viewers.ITableLabelProvider; +import org.eclipse.swt.graphics.Image; +import org.eclipse.swt.widgets.Table; + +/** + * Label Provider for {@link Table} objects displaying {@link WayPoint} objects. + */ +public class WayPointLabelProvider implements ITableLabelProvider { + + @Override + public Image getColumnImage(Object element, int columnIndex) { + return null; + } + + @Override + public String getColumnText(Object element, int columnIndex) { + if (element instanceof WayPoint) { + WayPoint wayPoint = (WayPoint)element; + switch (columnIndex) { + case 0: + return wayPoint.getName(); + case 1: + return String.format("%.6f", wayPoint.getLongitude()); + case 2: + return String.format("%.6f", wayPoint.getLatitude()); + case 3: + if (wayPoint.hasElevation()) { + return String.format("%.1f", wayPoint.getElevation()); + } else { + return "-"; + } + case 4: + return wayPoint.getDescription(); + } + } + + return null; + } + + @Override + public void addListener(ILabelProviderListener listener) { + // pass + } + + @Override + public void dispose() { + // pass + } + + @Override + public boolean isLabelProperty(Object element, String property) { + // pass + return false; + } + + @Override + public void removeListener(ILabelProviderListener listener) { + // pass + } +} diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/log/event/BugReportImporter.java b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/log/event/BugReportImporter.java new file mode 100644 index 00000000..da41e704 --- /dev/null +++ b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/log/event/BugReportImporter.java @@ -0,0 +1,96 @@ +/* + * Copyright (C) 2008 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.ddmuilib.log.event; + +import java.io.BufferedReader; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStreamReader; +import java.util.ArrayList; + +public class BugReportImporter { + + private final static String TAG_HEADER = "------ EVENT LOG TAGS ------"; + private final static String LOG_HEADER = "------ EVENT LOG ------"; + private final static String HEADER_TAG = "------"; + + private String[] mTags; + private String[] mLog; + + public BugReportImporter(String filePath) throws FileNotFoundException { + BufferedReader reader = new BufferedReader( + new InputStreamReader(new FileInputStream(filePath))); + + try { + String line; + while ((line = reader.readLine()) != null) { + if (TAG_HEADER.equals(line)) { + readTags(reader); + return; + } + } + } catch (IOException e) { + } finally { + if (reader != null) { + try { + reader.close(); + } catch (IOException ignore) { + } + } + } + } + + public String[] getTags() { + return mTags; + } + + public String[] getLog() { + return mLog; + } + + private void readTags(BufferedReader reader) throws IOException { + String line; + + ArrayList content = new ArrayList(); + while ((line = reader.readLine()) != null) { + if (LOG_HEADER.equals(line)) { + mTags = content.toArray(new String[content.size()]); + readLog(reader); + return; + } else { + content.add(line); + } + } + } + + private void readLog(BufferedReader reader) throws IOException { + String line; + + ArrayList content = new ArrayList(); + while ((line = reader.readLine()) != null) { + if (line.startsWith(HEADER_TAG) == false) { + content.add(line); + } else { + break; + } + } + + mLog = content.toArray(new String[content.size()]); + } + +} diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/log/event/DisplayFilteredLog.java b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/log/event/DisplayFilteredLog.java new file mode 100644 index 00000000..473387aa --- /dev/null +++ b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/log/event/DisplayFilteredLog.java @@ -0,0 +1,55 @@ +/* + * Copyright (C) 2008 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.ddmuilib.log.event; + +import com.android.ddmlib.log.EventContainer; +import com.android.ddmlib.log.EventLogParser; + +import java.util.ArrayList; + +public class DisplayFilteredLog extends DisplayLog { + + public DisplayFilteredLog(String name) { + super(name); + } + + /** + * Adds event to the display. + */ + @Override + void newEvent(EventContainer event, EventLogParser logParser) { + ArrayList valueDescriptors = + new ArrayList(); + + ArrayList occurrenceDescriptors = + new ArrayList(); + + if (filterEvent(event, valueDescriptors, occurrenceDescriptors)) { + addToLog(event, logParser, valueDescriptors, occurrenceDescriptors); + } + } + + /** + * Gets display type + * + * @return display type as an integer + */ + @Override + int getDisplayType() { + return DISPLAY_TYPE_FILTERED_LOG; + } +} diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/log/event/DisplayGraph.java b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/log/event/DisplayGraph.java new file mode 100644 index 00000000..0cffd7e0 --- /dev/null +++ b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/log/event/DisplayGraph.java @@ -0,0 +1,422 @@ +/* + * Copyright (C) 2008 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.ddmuilib.log.event; + +import com.android.ddmlib.log.EventContainer; +import com.android.ddmlib.log.EventLogParser; +import com.android.ddmlib.log.EventValueDescription; +import com.android.ddmlib.log.InvalidTypeException; +import org.eclipse.swt.widgets.Composite; +import org.eclipse.swt.widgets.Control; +import org.jfree.chart.axis.AxisLocation; +import org.jfree.chart.axis.NumberAxis; +import org.jfree.chart.plot.XYPlot; +import org.jfree.chart.renderer.xy.AbstractXYItemRenderer; +import org.jfree.chart.renderer.xy.XYAreaRenderer; +import org.jfree.chart.renderer.xy.XYLineAndShapeRenderer; +import org.jfree.data.time.Millisecond; +import org.jfree.data.time.TimeSeries; +import org.jfree.data.time.TimeSeriesCollection; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; + +public class DisplayGraph extends EventDisplay { + + public DisplayGraph(String name) { + super(name); + } + + /** + * Resets the display. + */ + @Override + void resetUI() { + Collection datasets = mValueTypeDataSetMap.values(); + for (TimeSeriesCollection dataset : datasets) { + dataset.removeAllSeries(); + } + if (mOccurrenceDataSet != null) { + mOccurrenceDataSet.removeAllSeries(); + } + mValueDescriptorSeriesMap.clear(); + mOcurrenceDescriptorSeriesMap.clear(); + } + + /** + * Creates the UI for the event display. + * @param parent the parent composite. + * @param logParser the current log parser. + * @return the created control (which may have children). + */ + @Override + public Control createComposite(final Composite parent, EventLogParser logParser, + final ILogColumnListener listener) { + String title = getChartTitle(logParser); + return createCompositeChart(parent, logParser, title); + } + + /** + * Adds event to the display. + */ + @Override + void newEvent(EventContainer event, EventLogParser logParser) { + ArrayList valueDescriptors = + new ArrayList(); + + ArrayList occurrenceDescriptors = + new ArrayList(); + + if (filterEvent(event, valueDescriptors, occurrenceDescriptors)) { + updateChart(event, logParser, valueDescriptors, occurrenceDescriptors); + } + } + + /** + * Updates the chart with the {@link EventContainer} by adding the values/occurrences defined + * by the {@link ValueDisplayDescriptor} and {@link OccurrenceDisplayDescriptor} objects from + * the two lists. + *

This method is only called when at least one of the descriptor list is non empty. + * @param event + * @param logParser + * @param valueDescriptors + * @param occurrenceDescriptors + */ + private void updateChart(EventContainer event, EventLogParser logParser, + ArrayList valueDescriptors, + ArrayList occurrenceDescriptors) { + Map tagMap = logParser.getTagMap(); + + Millisecond millisecondTime = null; + long msec = -1; + + // If the event container is a cpu container (tag == 2721), and there is no descriptor + // for the total CPU load, then we do accumulate all the values. + boolean accumulateValues = false; + double accumulatedValue = 0; + + if (event.mTag == 2721) { + accumulateValues = true; + for (ValueDisplayDescriptor descriptor : valueDescriptors) { + accumulateValues &= (descriptor.valueIndex != 0); + } + } + + for (ValueDisplayDescriptor descriptor : valueDescriptors) { + try { + // get the hashmap for this descriptor + HashMap map = mValueDescriptorSeriesMap.get(descriptor); + + // if it's not there yet, we create it. + if (map == null) { + map = new HashMap(); + mValueDescriptorSeriesMap.put(descriptor, map); + } + + // get the TimeSeries for this pid + TimeSeries timeSeries = map.get(event.pid); + + // if it doesn't exist yet, we create it + if (timeSeries == null) { + // get the series name + String seriesFullName = null; + String seriesLabel = getSeriesLabel(event, descriptor); + + switch (mValueDescriptorCheck) { + case EVENT_CHECK_SAME_TAG: + seriesFullName = String.format("%1$s / %2$s", seriesLabel, + descriptor.valueName); + break; + case EVENT_CHECK_SAME_VALUE: + seriesFullName = String.format("%1$s", seriesLabel); + break; + default: + seriesFullName = String.format("%1$s / %2$s: %3$s", seriesLabel, + tagMap.get(descriptor.eventTag), + descriptor.valueName); + break; + } + + // get the data set for this ValueType + TimeSeriesCollection dataset = getValueDataset( + logParser.getEventInfoMap().get(event.mTag)[descriptor.valueIndex] + .getValueType(), + accumulateValues); + + // create the series + timeSeries = new TimeSeries(seriesFullName, Millisecond.class); + if (mMaximumChartItemAge != -1) { + timeSeries.setMaximumItemAge(mMaximumChartItemAge * 1000); + } + + dataset.addSeries(timeSeries); + + // add it to the map. + map.put(event.pid, timeSeries); + } + + // update the timeSeries. + + // get the value from the event + double value = event.getValueAsDouble(descriptor.valueIndex); + + // accumulate the values if needed. + if (accumulateValues) { + accumulatedValue += value; + value = accumulatedValue; + } + + // get the time + if (millisecondTime == null) { + msec = (long)event.sec * 1000L + (event.nsec / 1000000L); + millisecondTime = new Millisecond(new Date(msec)); + } + + // add the value to the time series + timeSeries.addOrUpdate(millisecondTime, value); + } catch (InvalidTypeException e) { + // just ignore this descriptor if there's a type mismatch + } + } + + for (OccurrenceDisplayDescriptor descriptor : occurrenceDescriptors) { + try { + // get the hashmap for this descriptor + HashMap map = mOcurrenceDescriptorSeriesMap.get(descriptor); + + // if it's not there yet, we create it. + if (map == null) { + map = new HashMap(); + mOcurrenceDescriptorSeriesMap.put(descriptor, map); + } + + // get the TimeSeries for this pid + TimeSeries timeSeries = map.get(event.pid); + + // if it doesn't exist yet, we create it. + if (timeSeries == null) { + String seriesLabel = getSeriesLabel(event, descriptor); + + String seriesFullName = String.format("[%1$s:%2$s]", + tagMap.get(descriptor.eventTag), seriesLabel); + + timeSeries = new TimeSeries(seriesFullName, Millisecond.class); + if (mMaximumChartItemAge != -1) { + timeSeries.setMaximumItemAge(mMaximumChartItemAge); + } + + getOccurrenceDataSet().addSeries(timeSeries); + + map.put(event.pid, timeSeries); + } + + // update the series + + // get the time + if (millisecondTime == null) { + msec = (long)event.sec * 1000L + (event.nsec / 1000000L); + millisecondTime = new Millisecond(new Date(msec)); + } + + // add the value to the time series + timeSeries.addOrUpdate(millisecondTime, 0); // the value is unused + } catch (InvalidTypeException e) { + // just ignore this descriptor if there's a type mismatch + } + } + + // go through all the series and remove old values. + if (msec != -1 && mMaximumChartItemAge != -1) { + Collection> pidMapValues = + mValueDescriptorSeriesMap.values(); + + for (HashMap pidMapValue : pidMapValues) { + Collection seriesCollection = pidMapValue.values(); + + for (TimeSeries timeSeries : seriesCollection) { + timeSeries.removeAgedItems(msec, true); + } + } + + pidMapValues = mOcurrenceDescriptorSeriesMap.values(); + for (HashMap pidMapValue : pidMapValues) { + Collection seriesCollection = pidMapValue.values(); + + for (TimeSeries timeSeries : seriesCollection) { + timeSeries.removeAgedItems(msec, true); + } + } + } + } + + /** + * Returns a {@link TimeSeriesCollection} for a specific {@link com.android.ddmlib.log.EventValueDescription.ValueType}. + * If the data set is not yet created, it is first allocated and set up into the + * {@link org.jfree.chart.JFreeChart} object. + * @param type the {@link com.android.ddmlib.log.EventValueDescription.ValueType} of the data set. + * @param accumulateValues + */ + private TimeSeriesCollection getValueDataset(EventValueDescription.ValueType type, boolean accumulateValues) { + TimeSeriesCollection dataset = mValueTypeDataSetMap.get(type); + if (dataset == null) { + // create the data set and store it in the map + dataset = new TimeSeriesCollection(); + mValueTypeDataSetMap.put(type, dataset); + + // create the renderer and configure it depending on the ValueType + AbstractXYItemRenderer renderer; + if (type == EventValueDescription.ValueType.PERCENT && accumulateValues) { + renderer = new XYAreaRenderer(); + } else { + XYLineAndShapeRenderer r = new XYLineAndShapeRenderer(); + r.setBaseShapesVisible(type != EventValueDescription.ValueType.PERCENT); + + renderer = r; + } + + // set both the dataset and the renderer in the plot object. + XYPlot xyPlot = mChart.getXYPlot(); + xyPlot.setDataset(mDataSetCount, dataset); + xyPlot.setRenderer(mDataSetCount, renderer); + + // put a new axis label, and configure it. + NumberAxis axis = new NumberAxis(type.toString()); + + if (type == EventValueDescription.ValueType.PERCENT) { + // force percent range to be (0,100) fixed. + axis.setAutoRange(false); + axis.setRange(0., 100.); + } + + // for the index, we ignore the occurrence dataset + int count = mDataSetCount; + if (mOccurrenceDataSet != null) { + count--; + } + + xyPlot.setRangeAxis(count, axis); + if ((count % 2) == 0) { + xyPlot.setRangeAxisLocation(count, AxisLocation.BOTTOM_OR_LEFT); + } else { + xyPlot.setRangeAxisLocation(count, AxisLocation.TOP_OR_RIGHT); + } + + // now we link the dataset and the axis + xyPlot.mapDatasetToRangeAxis(mDataSetCount, count); + + mDataSetCount++; + } + + return dataset; + } + + /** + * Return the series label for this event. This only contains the pid information. + * @param event the {@link EventContainer} + * @param descriptor the {@link OccurrenceDisplayDescriptor} + * @return the series label. + * @throws InvalidTypeException + */ + private String getSeriesLabel(EventContainer event, OccurrenceDisplayDescriptor descriptor) + throws InvalidTypeException { + if (descriptor.seriesValueIndex != -1) { + if (descriptor.includePid == false) { + return event.getValueAsString(descriptor.seriesValueIndex); + } else { + return String.format("%1$s (%2$d)", + event.getValueAsString(descriptor.seriesValueIndex), event.pid); + } + } + + return Integer.toString(event.pid); + } + + /** + * Returns the {@link TimeSeriesCollection} for the occurrence display. If the data set is not + * yet created, it is first allocated and set up into the {@link org.jfree.chart.JFreeChart} object. + */ + private TimeSeriesCollection getOccurrenceDataSet() { + if (mOccurrenceDataSet == null) { + mOccurrenceDataSet = new TimeSeriesCollection(); + + XYPlot xyPlot = mChart.getXYPlot(); + xyPlot.setDataset(mDataSetCount, mOccurrenceDataSet); + + OccurrenceRenderer renderer = new OccurrenceRenderer(); + renderer.setBaseShapesVisible(false); + xyPlot.setRenderer(mDataSetCount, renderer); + + mDataSetCount++; + } + + return mOccurrenceDataSet; + } + + /** + * Gets display type + * + * @return display type as an integer + */ + @Override + int getDisplayType() { + return DISPLAY_TYPE_GRAPH; + } + + /** + * Sets the current {@link EventLogParser} object. + */ + @Override + protected void setNewLogParser(EventLogParser logParser) { + if (mChart != null) { + mChart.setTitle(getChartTitle(logParser)); + } + } + /** + * Returns a meaningful chart title based on the value of {@link #mValueDescriptorCheck}. + * + * @param logParser the logParser. + * @return the chart title. + */ + private String getChartTitle(EventLogParser logParser) { + if (mValueDescriptors.size() > 0) { + String chartDesc = null; + switch (mValueDescriptorCheck) { + case EVENT_CHECK_SAME_TAG: + if (logParser != null) { + chartDesc = logParser.getTagMap().get(mValueDescriptors.get(0).eventTag); + } + break; + case EVENT_CHECK_SAME_VALUE: + if (logParser != null) { + chartDesc = String.format("%1$s / %2$s", + logParser.getTagMap().get(mValueDescriptors.get(0).eventTag), + mValueDescriptors.get(0).valueName); + } + break; + } + + if (chartDesc != null) { + return String.format("%1$s - %2$s", mName, chartDesc); + } + } + + return mName; + } +} \ No newline at end of file diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/log/event/DisplayLog.java b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/log/event/DisplayLog.java new file mode 100644 index 00000000..8e7c1ac9 --- /dev/null +++ b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/log/event/DisplayLog.java @@ -0,0 +1,381 @@ +/* + * Copyright (C) 2008 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.ddmuilib.log.event; + +import com.android.ddmlib.log.EventContainer; +import com.android.ddmlib.log.EventLogParser; +import com.android.ddmlib.log.EventValueDescription; +import com.android.ddmlib.log.InvalidTypeException; +import com.android.ddmuilib.DdmUiPreferences; +import com.android.ddmuilib.TableHelper; + +import org.eclipse.jface.preference.IPreferenceStore; +import org.eclipse.swt.SWT; +import org.eclipse.swt.events.ControlAdapter; +import org.eclipse.swt.events.ControlEvent; +import org.eclipse.swt.events.DisposeEvent; +import org.eclipse.swt.events.DisposeListener; +import org.eclipse.swt.layout.GridData; +import org.eclipse.swt.layout.GridLayout; +import org.eclipse.swt.widgets.Composite; +import org.eclipse.swt.widgets.Control; +import org.eclipse.swt.widgets.Label; +import org.eclipse.swt.widgets.ScrollBar; +import org.eclipse.swt.widgets.Table; +import org.eclipse.swt.widgets.TableColumn; +import org.eclipse.swt.widgets.TableItem; + +import java.util.ArrayList; +import java.util.Calendar; + +public class DisplayLog extends EventDisplay { + public DisplayLog(String name) { + super(name); + } + + private final static String PREFS_COL_DATE = "EventLogPanel.log.Col1"; //$NON-NLS-1$ + private final static String PREFS_COL_PID = "EventLogPanel.log.Col2"; //$NON-NLS-1$ + private final static String PREFS_COL_EVENTTAG = "EventLogPanel.log.Col3"; //$NON-NLS-1$ + private final static String PREFS_COL_VALUENAME = "EventLogPanel.log.Col4"; //$NON-NLS-1$ + private final static String PREFS_COL_VALUE = "EventLogPanel.log.Col5"; //$NON-NLS-1$ + private final static String PREFS_COL_TYPE = "EventLogPanel.log.Col6"; //$NON-NLS-1$ + + /** + * Resets the display. + */ + @Override + void resetUI() { + mLogTable.removeAll(); + } + + /** + * Adds event to the display. + */ + @Override + void newEvent(EventContainer event, EventLogParser logParser) { + addToLog(event, logParser); + } + + /** + * Creates the UI for the event display. + * + * @param parent the parent composite. + * @param logParser the current log parser. + * @return the created control (which may have children). + */ + @Override + Control createComposite(Composite parent, EventLogParser logParser, ILogColumnListener listener) { + return createLogUI(parent, listener); + } + + /** + * Adds an {@link EventContainer} to the log. + * + * @param event the event. + * @param logParser the log parser. + */ + private void addToLog(EventContainer event, EventLogParser logParser) { + ScrollBar bar = mLogTable.getVerticalBar(); + boolean scroll = bar.getMaximum() == bar.getSelection() + bar.getThumb(); + + // get the date. + Calendar c = Calendar.getInstance(); + long msec = event.sec * 1000L; + c.setTimeInMillis(msec); + + // convert the time into a string + String date = String.format("%1$tF %1$tT", c); + + String eventName = logParser.getTagMap().get(event.mTag); + String pidName = Integer.toString(event.pid); + + // get the value description + EventValueDescription[] valueDescription = logParser.getEventInfoMap().get(event.mTag); + if (valueDescription != null) { + for (int i = 0; i < valueDescription.length; i++) { + EventValueDescription description = valueDescription[i]; + try { + String value = event.getValueAsString(i); + + logValue(date, pidName, eventName, description.getName(), value, + description.getEventValueType(), description.getValueType()); + } catch (InvalidTypeException e) { + logValue(date, pidName, eventName, description.getName(), e.getMessage(), + description.getEventValueType(), description.getValueType()); + } + } + + // scroll if needed, by showing the last item + if (scroll) { + int itemCount = mLogTable.getItemCount(); + if (itemCount > 0) { + mLogTable.showItem(mLogTable.getItem(itemCount - 1)); + } + } + } + } + + /** + * Adds an {@link EventContainer} to the log. Only add the values/occurrences defined by + * the list of descriptors. If an event is configured to be displayed by value and occurrence, + * only the values are displayed (as they mark an event occurrence anyway). + *

This method is only called when at least one of the descriptor list is non empty. + * + * @param event + * @param logParser + * @param valueDescriptors + * @param occurrenceDescriptors + */ + protected void addToLog(EventContainer event, EventLogParser logParser, + ArrayList valueDescriptors, + ArrayList occurrenceDescriptors) { + ScrollBar bar = mLogTable.getVerticalBar(); + boolean scroll = bar.getMaximum() == bar.getSelection() + bar.getThumb(); + + // get the date. + Calendar c = Calendar.getInstance(); + long msec = event.sec * 1000L; + c.setTimeInMillis(msec); + + // convert the time into a string + String date = String.format("%1$tF %1$tT", c); + + String eventName = logParser.getTagMap().get(event.mTag); + String pidName = Integer.toString(event.pid); + + if (valueDescriptors.size() > 0) { + for (ValueDisplayDescriptor descriptor : valueDescriptors) { + logDescriptor(event, descriptor, date, pidName, eventName, logParser); + } + } else { + // we display the event. Since the StringBuilder contains the header (date, event name, + // pid) at this point, there isn't anything else to display. + } + + // scroll if needed, by showing the last item + if (scroll) { + int itemCount = mLogTable.getItemCount(); + if (itemCount > 0) { + mLogTable.showItem(mLogTable.getItem(itemCount - 1)); + } + } + } + + + /** + * Logs a value in the ui. + * + * @param date + * @param pid + * @param event + * @param valueName + * @param value + * @param eventValueType + * @param valueType + */ + private void logValue(String date, String pid, String event, String valueName, + String value, EventContainer.EventValueType eventValueType, EventValueDescription.ValueType valueType) { + + TableItem item = new TableItem(mLogTable, SWT.NONE); + item.setText(0, date); + item.setText(1, pid); + item.setText(2, event); + item.setText(3, valueName); + item.setText(4, value); + + String type; + if (valueType != EventValueDescription.ValueType.NOT_APPLICABLE) { + type = String.format("%1$s, %2$s", eventValueType.toString(), valueType.toString()); + } else { + type = eventValueType.toString(); + } + + item.setText(5, type); + } + + /** + * Logs a value from an {@link EventContainer} as defined by the {@link ValueDisplayDescriptor}. + * + * @param event the EventContainer + * @param descriptor the ValueDisplayDescriptor defining which value to display. + * @param date the date of the event in a string. + * @param pidName + * @param eventName + * @param logParser + */ + private void logDescriptor(EventContainer event, ValueDisplayDescriptor descriptor, + String date, String pidName, String eventName, EventLogParser logParser) { + + String value; + try { + value = event.getValueAsString(descriptor.valueIndex); + } catch (InvalidTypeException e) { + value = e.getMessage(); + } + + EventValueDescription[] values = logParser.getEventInfoMap().get(event.mTag); + + EventValueDescription valueDescription = values[descriptor.valueIndex]; + + logValue(date, pidName, eventName, descriptor.valueName, value, + valueDescription.getEventValueType(), valueDescription.getValueType()); + } + + /** + * Creates the UI for a log display. + * + * @param parent the parent {@link Composite} + * @param listener the {@link ILogColumnListener} to notify on column resize events. + * @return the top Composite of the UI. + */ + private Control createLogUI(Composite parent, final ILogColumnListener listener) { + Composite mainComp = new Composite(parent, SWT.NONE); + GridLayout gl; + mainComp.setLayout(gl = new GridLayout(1, false)); + gl.marginHeight = gl.marginWidth = 0; + mainComp.addDisposeListener(new DisposeListener() { + @Override + public void widgetDisposed(DisposeEvent e) { + mLogTable = null; + } + }); + + Label l = new Label(mainComp, SWT.CENTER); + l.setText(mName); + l.setLayoutData(new GridData(GridData.FILL_HORIZONTAL)); + + mLogTable = new Table(mainComp, SWT.MULTI | SWT.FULL_SELECTION | SWT.V_SCROLL | + SWT.BORDER); + mLogTable.setLayoutData(new GridData(GridData.FILL_BOTH)); + + IPreferenceStore store = DdmUiPreferences.getStore(); + + TableColumn col = TableHelper.createTableColumn( + mLogTable, "Time", + SWT.LEFT, "0000-00-00 00:00:00", PREFS_COL_DATE, store); //$NON-NLS-1$ + col.addControlListener(new ControlAdapter() { + @Override + public void controlResized(ControlEvent e) { + Object source = e.getSource(); + if (source instanceof TableColumn) { + listener.columnResized(0, (TableColumn) source); + } + } + }); + + col = TableHelper.createTableColumn( + mLogTable, "pid", + SWT.LEFT, "0000", PREFS_COL_PID, store); //$NON-NLS-1$ + col.addControlListener(new ControlAdapter() { + @Override + public void controlResized(ControlEvent e) { + Object source = e.getSource(); + if (source instanceof TableColumn) { + listener.columnResized(1, (TableColumn) source); + } + } + }); + + col = TableHelper.createTableColumn( + mLogTable, "Event", + SWT.LEFT, "abcdejghijklmno", PREFS_COL_EVENTTAG, store); //$NON-NLS-1$ + col.addControlListener(new ControlAdapter() { + @Override + public void controlResized(ControlEvent e) { + Object source = e.getSource(); + if (source instanceof TableColumn) { + listener.columnResized(2, (TableColumn) source); + } + } + }); + + col = TableHelper.createTableColumn( + mLogTable, "Name", + SWT.LEFT, "Process Name", PREFS_COL_VALUENAME, store); //$NON-NLS-1$ + col.addControlListener(new ControlAdapter() { + @Override + public void controlResized(ControlEvent e) { + Object source = e.getSource(); + if (source instanceof TableColumn) { + listener.columnResized(3, (TableColumn) source); + } + } + }); + + col = TableHelper.createTableColumn( + mLogTable, "Value", + SWT.LEFT, "0000000", PREFS_COL_VALUE, store); //$NON-NLS-1$ + col.addControlListener(new ControlAdapter() { + @Override + public void controlResized(ControlEvent e) { + Object source = e.getSource(); + if (source instanceof TableColumn) { + listener.columnResized(4, (TableColumn) source); + } + } + }); + + col = TableHelper.createTableColumn( + mLogTable, "Type", + SWT.LEFT, "long, seconds", PREFS_COL_TYPE, store); //$NON-NLS-1$ + col.addControlListener(new ControlAdapter() { + @Override + public void controlResized(ControlEvent e) { + Object source = e.getSource(); + if (source instanceof TableColumn) { + listener.columnResized(5, (TableColumn) source); + } + } + }); + + mLogTable.setHeaderVisible(true); + mLogTable.setLinesVisible(true); + + return mainComp; + } + + /** + * Resizes the index-th column of the log {@link Table} (if applicable). + *

+ * This does nothing if the Table object is null (because the display + * type does not use a column) or if the index-th column is in fact the originating + * column passed as argument. + * + * @param index the index of the column to resize + * @param sourceColumn the original column that was resize, and on which we need to sync the + * index-th column width. + */ + @Override + void resizeColumn(int index, TableColumn sourceColumn) { + if (mLogTable != null) { + TableColumn col = mLogTable.getColumn(index); + if (col != sourceColumn) { + col.setWidth(sourceColumn.getWidth()); + } + } + } + + /** + * Gets display type + * + * @return display type as an integer + */ + @Override + int getDisplayType() { + return DISPLAY_TYPE_LOG_ALL; + } +} diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/log/event/DisplaySync.java b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/log/event/DisplaySync.java new file mode 100644 index 00000000..6122513d --- /dev/null +++ b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/log/event/DisplaySync.java @@ -0,0 +1,304 @@ +/* + * Copyright (C) 2008 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.ddmuilib.log.event; + +import com.android.ddmlib.log.EventContainer; +import com.android.ddmlib.log.EventLogParser; +import com.android.ddmlib.log.InvalidTypeException; + +import org.eclipse.swt.widgets.Composite; +import org.eclipse.swt.widgets.Control; +import org.jfree.chart.labels.CustomXYToolTipGenerator; +import org.jfree.chart.plot.XYPlot; +import org.jfree.chart.renderer.xy.XYBarRenderer; +import org.jfree.chart.renderer.xy.XYLineAndShapeRenderer; +import org.jfree.data.time.FixedMillisecond; +import org.jfree.data.time.SimpleTimePeriod; +import org.jfree.data.time.TimePeriodValues; +import org.jfree.data.time.TimePeriodValuesCollection; +import org.jfree.data.time.TimeSeries; +import org.jfree.data.time.TimeSeriesCollection; +import org.jfree.util.ShapeUtilities; + +import java.awt.Color; +import java.util.ArrayList; +import java.util.List; +import java.util.Scanner; +import java.util.regex.Pattern; + +public class DisplaySync extends SyncCommon { + + // Information to graph for each authority + private TimePeriodValues mDatasetsSync[]; + private List mTooltipsSync[]; + private CustomXYToolTipGenerator mTooltipGenerators[]; + private TimeSeries mDatasetsSyncTickle[]; + + // Dataset of error events to graph + private TimeSeries mDatasetError; + + public DisplaySync(String name) { + super(name); + } + + /** + * Creates the UI for the event display. + * @param parent the parent composite. + * @param logParser the current log parser. + * @return the created control (which may have children). + */ + @Override + public Control createComposite(final Composite parent, EventLogParser logParser, + final ILogColumnListener listener) { + Control composite = createCompositeChart(parent, logParser, "Sync Status"); + resetUI(); + return composite; + } + + /** + * Resets the display. + */ + @Override + void resetUI() { + super.resetUI(); + XYPlot xyPlot = mChart.getXYPlot(); + + XYBarRenderer br = new XYBarRenderer(); + mDatasetsSync = new TimePeriodValues[NUM_AUTHS]; + + @SuppressWarnings("unchecked") + List mTooltipsSyncTmp[] = new List[NUM_AUTHS]; + mTooltipsSync = mTooltipsSyncTmp; + + mTooltipGenerators = new CustomXYToolTipGenerator[NUM_AUTHS]; + + TimePeriodValuesCollection tpvc = new TimePeriodValuesCollection(); + xyPlot.setDataset(tpvc); + xyPlot.setRenderer(0, br); + + XYLineAndShapeRenderer ls = new XYLineAndShapeRenderer(); + ls.setBaseLinesVisible(false); + mDatasetsSyncTickle = new TimeSeries[NUM_AUTHS]; + TimeSeriesCollection tsc = new TimeSeriesCollection(); + xyPlot.setDataset(1, tsc); + xyPlot.setRenderer(1, ls); + + mDatasetError = new TimeSeries("Errors", FixedMillisecond.class); + xyPlot.setDataset(2, new TimeSeriesCollection(mDatasetError)); + XYLineAndShapeRenderer errls = new XYLineAndShapeRenderer(); + errls.setBaseLinesVisible(false); + errls.setSeriesPaint(0, Color.RED); + xyPlot.setRenderer(2, errls); + + for (int i = 0; i < NUM_AUTHS; i++) { + br.setSeriesPaint(i, AUTH_COLORS[i]); + ls.setSeriesPaint(i, AUTH_COLORS[i]); + mDatasetsSync[i] = new TimePeriodValues(AUTH_NAMES[i]); + tpvc.addSeries(mDatasetsSync[i]); + mTooltipsSync[i] = new ArrayList(); + mTooltipGenerators[i] = new CustomXYToolTipGenerator(); + br.setSeriesToolTipGenerator(i, mTooltipGenerators[i]); + mTooltipGenerators[i].addToolTipSeries(mTooltipsSync[i]); + + mDatasetsSyncTickle[i] = new TimeSeries(AUTH_NAMES[i] + " tickle", + FixedMillisecond.class); + tsc.addSeries(mDatasetsSyncTickle[i]); + ls.setSeriesShape(i, ShapeUtilities.createUpTriangle(2.5f)); + } + } + + /** + * Updates the display with a new event. + * + * @param event The event + * @param logParser The parser providing the event. + */ + @Override + void newEvent(EventContainer event, EventLogParser logParser) { + super.newEvent(event, logParser); // Handle sync operation + try { + if (event.mTag == EVENT_TICKLE) { + int auth = getAuth(event.getValueAsString(0)); + if (auth >= 0) { + long msec = event.sec * 1000L + (event.nsec / 1000000L); + mDatasetsSyncTickle[auth].addOrUpdate(new FixedMillisecond(msec), -1); + } + } + } catch (InvalidTypeException e) { + } + } + + /** + * Generate the height for an event. + * Height is somewhat arbitrarily the count of "things" that happened + * during the sync. + * When network traffic measurements are available, code should be modified + * to use that instead. + * @param details The details string associated with the event + * @return The height in arbirary units (0-100) + */ + private int getHeightFromDetails(String details) { + if (details == null) { + return 1; // Arbitrary + } + int total = 0; + String parts[] = details.split("[a-zA-Z]"); + for (String part : parts) { + if ("".equals(part)) continue; + total += Integer.parseInt(part); + } + if (total == 0) { + total = 1; + } + return total; + } + + /** + * Generates the tooltips text for an event. + * This method decodes the cryptic details string. + * @param auth The authority associated with the event + * @param details The details string + * @param eventSource server, poll, etc. + * @return The text to display in the tooltips + */ + private String getTextFromDetails(int auth, String details, int eventSource) { + + StringBuffer sb = new StringBuffer(); + sb.append(AUTH_NAMES[auth]).append(": \n"); + + Scanner scanner = new Scanner(details); + Pattern charPat = Pattern.compile("[a-zA-Z]"); + Pattern numPat = Pattern.compile("[0-9]+"); + while (scanner.hasNext()) { + String key = scanner.findInLine(charPat); + int val = Integer.parseInt(scanner.findInLine(numPat)); + if (auth == GMAIL && "M".equals(key)) { + sb.append("messages from server: ").append(val).append("\n"); + } else if (auth == GMAIL && "L".equals(key)) { + sb.append("labels from server: ").append(val).append("\n"); + } else if (auth == GMAIL && "C".equals(key)) { + sb.append("check conversation requests from server: ").append(val).append("\n"); + } else if (auth == GMAIL && "A".equals(key)) { + sb.append("attachments from server: ").append(val).append("\n"); + } else if (auth == GMAIL && "U".equals(key)) { + sb.append("op updates from server: ").append(val).append("\n"); + } else if (auth == GMAIL && "u".equals(key)) { + sb.append("op updates to server: ").append(val).append("\n"); + } else if (auth == GMAIL && "S".equals(key)) { + sb.append("send/receive cycles: ").append(val).append("\n"); + } else if ("Q".equals(key)) { + sb.append("queries to server: ").append(val).append("\n"); + } else if ("E".equals(key)) { + sb.append("entries from server: ").append(val).append("\n"); + } else if ("u".equals(key)) { + sb.append("updates from client: ").append(val).append("\n"); + } else if ("i".equals(key)) { + sb.append("inserts from client: ").append(val).append("\n"); + } else if ("d".equals(key)) { + sb.append("deletes from client: ").append(val).append("\n"); + } else if ("f".equals(key)) { + sb.append("full sync requested\n"); + } else if ("r".equals(key)) { + sb.append("partial sync unavailable\n"); + } else if ("X".equals(key)) { + sb.append("hard error\n"); + } else if ("e".equals(key)) { + sb.append("number of parse exceptions: ").append(val).append("\n"); + } else if ("c".equals(key)) { + sb.append("number of conflicts: ").append(val).append("\n"); + } else if ("a".equals(key)) { + sb.append("number of auth exceptions: ").append(val).append("\n"); + } else if ("D".equals(key)) { + sb.append("too many deletions\n"); + } else if ("R".equals(key)) { + sb.append("too many retries: ").append(val).append("\n"); + } else if ("b".equals(key)) { + sb.append("database error\n"); + } else if ("x".equals(key)) { + sb.append("soft error\n"); + } else if ("l".equals(key)) { + sb.append("sync already in progress\n"); + } else if ("I".equals(key)) { + sb.append("io exception\n"); + } else if (auth == CONTACTS && "g".equals(key)) { + sb.append("aggregation query: ").append(val).append("\n"); + } else if (auth == CONTACTS && "G".equals(key)) { + sb.append("aggregation merge: ").append(val).append("\n"); + } else if (auth == CONTACTS && "n".equals(key)) { + sb.append("num entries: ").append(val).append("\n"); + } else if (auth == CONTACTS && "p".equals(key)) { + sb.append("photos uploaded from server: ").append(val).append("\n"); + } else if (auth == CONTACTS && "P".equals(key)) { + sb.append("photos downloaded from server: ").append(val).append("\n"); + } else if (auth == CALENDAR && "F".equals(key)) { + sb.append("server refresh\n"); + } else if (auth == CALENDAR && "s".equals(key)) { + sb.append("server diffs fetched\n"); + } else { + sb.append(key).append("=").append(val); + } + } + if (eventSource == 0) { + sb.append("(server)"); + } else if (eventSource == 1) { + sb.append("(local)"); + } else if (eventSource == 2) { + sb.append("(poll)"); + } else if (eventSource == 3) { + sb.append("(user)"); + } + return sb.toString(); + } + + + /** + * Callback to process a sync event. + */ + @Override + void processSyncEvent(EventContainer event, int auth, long startTime, long stopTime, + String details, boolean newEvent, int syncSource) { + if (!newEvent) { + // Details arrived for a previous sync event + // Remove event before reinserting. + int lastItem = mDatasetsSync[auth].getItemCount(); + mDatasetsSync[auth].delete(lastItem-1, lastItem-1); + mTooltipsSync[auth].remove(lastItem-1); + } + double height = getHeightFromDetails(details); + height = height / (stopTime - startTime + 1) * 10000; + if (height > 30) { + height = 30; + } + mDatasetsSync[auth].add(new SimpleTimePeriod(startTime, stopTime), height); + mTooltipsSync[auth].add(getTextFromDetails(auth, details, syncSource)); + mTooltipGenerators[auth].addToolTipSeries(mTooltipsSync[auth]); + if (details.indexOf('x') >= 0 || details.indexOf('X') >= 0) { + long msec = event.sec * 1000L + (event.nsec / 1000000L); + mDatasetError.addOrUpdate(new FixedMillisecond(msec), -1); + } + } + + /** + * Gets display type + * + * @return display type as an integer + */ + @Override + int getDisplayType() { + return DISPLAY_TYPE_SYNC; + } +} diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/log/event/DisplaySyncHistogram.java b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/log/event/DisplaySyncHistogram.java new file mode 100644 index 00000000..5bfc0396 --- /dev/null +++ b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/log/event/DisplaySyncHistogram.java @@ -0,0 +1,181 @@ +/* + * Copyright (C) 2008 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.ddmuilib.log.event; + +import com.android.ddmlib.log.EventContainer; +import com.android.ddmlib.log.EventLogParser; + +import org.eclipse.swt.widgets.Composite; +import org.eclipse.swt.widgets.Control; +import org.jfree.chart.plot.XYPlot; +import org.jfree.chart.renderer.xy.AbstractXYItemRenderer; +import org.jfree.chart.renderer.xy.XYBarRenderer; +import org.jfree.data.time.RegularTimePeriod; +import org.jfree.data.time.SimpleTimePeriod; +import org.jfree.data.time.TimePeriodValues; +import org.jfree.data.time.TimePeriodValuesCollection; + +import java.util.Calendar; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; +import java.util.TimeZone; + +public class DisplaySyncHistogram extends SyncCommon { + + Map mTimePeriodMap[]; + + // Information to graph for each authority + private TimePeriodValues mDatasetsSyncHist[]; + + public DisplaySyncHistogram(String name) { + super(name); + } + + /** + * Creates the UI for the event display. + * @param parent the parent composite. + * @param logParser the current log parser. + * @return the created control (which may have children). + */ + @Override + public Control createComposite(final Composite parent, EventLogParser logParser, + final ILogColumnListener listener) { + Control composite = createCompositeChart(parent, logParser, "Sync Histogram"); + resetUI(); + return composite; + } + + /** + * Resets the display. + */ + @Override + void resetUI() { + super.resetUI(); + XYPlot xyPlot = mChart.getXYPlot(); + + AbstractXYItemRenderer br = new XYBarRenderer(); + mDatasetsSyncHist = new TimePeriodValues[NUM_AUTHS+1]; + + @SuppressWarnings("unchecked") + Map mTimePeriodMapTmp[] = new HashMap[NUM_AUTHS + 1]; + mTimePeriodMap = mTimePeriodMapTmp; + + TimePeriodValuesCollection tpvc = new TimePeriodValuesCollection(); + xyPlot.setDataset(tpvc); + xyPlot.setRenderer(br); + + for (int i = 0; i < NUM_AUTHS + 1; i++) { + br.setSeriesPaint(i, AUTH_COLORS[i]); + mDatasetsSyncHist[i] = new TimePeriodValues(AUTH_NAMES[i]); + tpvc.addSeries(mDatasetsSyncHist[i]); + mTimePeriodMap[i] = new HashMap(); + + } + } + + /** + * Callback to process a sync event. + * + * @param event The sync event + * @param startTime Start time (ms) of events + * @param stopTime Stop time (ms) of events + * @param details Details associated with the event. + * @param newEvent True if this event is a new sync event. False if this event + * @param syncSource + */ + @Override + void processSyncEvent(EventContainer event, int auth, long startTime, long stopTime, + String details, boolean newEvent, int syncSource) { + if (newEvent) { + if (details.indexOf('x') >= 0 || details.indexOf('X') >= 0) { + auth = ERRORS; + } + double delta = (stopTime - startTime) * 100. / 1000 / 3600; // Percent of hour + addHistEvent(0, auth, delta); + } else { + // sync_details arrived for an event that has already been graphed. + if (details.indexOf('x') >= 0 || details.indexOf('X') >= 0) { + // Item turns out to be in error, so transfer time from old auth to error. + double delta = (stopTime - startTime) * 100. / 1000 / 3600; // Percent of hour + addHistEvent(0, auth, -delta); + addHistEvent(0, ERRORS, delta); + } + } + } + + /** + * Helper to add an event to the data series. + * Also updates error series if appropriate (x or X in details). + * @param stopTime Time event ends + * @param auth Sync authority + * @param value Value to graph for event + */ + private void addHistEvent(long stopTime, int auth, double value) { + SimpleTimePeriod hour = getTimePeriod(stopTime, mHistWidth); + + // Loop over all datasets to do the stacking. + for (int i = auth; i <= ERRORS; i++) { + addToPeriod(mDatasetsSyncHist, i, hour, value); + } + } + + private void addToPeriod(TimePeriodValues tpv[], int auth, SimpleTimePeriod period, + double value) { + int index; + if (mTimePeriodMap[auth].containsKey(period)) { + index = mTimePeriodMap[auth].get(period); + double oldValue = tpv[auth].getValue(index).doubleValue(); + tpv[auth].update(index, oldValue + value); + } else { + index = tpv[auth].getItemCount(); + mTimePeriodMap[auth].put(period, index); + tpv[auth].add(period, value); + } + } + + /** + * Creates a multiple-hour time period for the histogram. + * @param time Time in milliseconds. + * @param numHoursWide: should divide into a day. + * @return SimpleTimePeriod covering the number of hours and containing time. + */ + private SimpleTimePeriod getTimePeriod(long time, long numHoursWide) { + Date date = new Date(time); + TimeZone zone = RegularTimePeriod.DEFAULT_TIME_ZONE; + Calendar calendar = Calendar.getInstance(zone); + calendar.setTime(date); + long hoursOfYear = calendar.get(Calendar.HOUR_OF_DAY) + + calendar.get(Calendar.DAY_OF_YEAR) * 24; + int year = calendar.get(Calendar.YEAR); + hoursOfYear = (hoursOfYear / numHoursWide) * numHoursWide; + calendar.clear(); + calendar.set(year, 0, 1, 0, 0); // Jan 1 + long start = calendar.getTimeInMillis() + hoursOfYear * 3600 * 1000; + return new SimpleTimePeriod(start, start + numHoursWide * 3600 * 1000); + } + + /** + * Gets display type + * + * @return display type as an integer + */ + @Override + int getDisplayType() { + return DISPLAY_TYPE_SYNC_HIST; + } +} diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/log/event/DisplaySyncPerf.java b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/log/event/DisplaySyncPerf.java new file mode 100644 index 00000000..10176e3b --- /dev/null +++ b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/log/event/DisplaySyncPerf.java @@ -0,0 +1,227 @@ +/* + * Copyright (C) 2009 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.ddmuilib.log.event; + +import com.android.ddmlib.log.EventContainer; +import com.android.ddmlib.log.EventLogParser; +import com.android.ddmlib.log.InvalidTypeException; + +import org.eclipse.swt.widgets.Composite; +import org.eclipse.swt.widgets.Control; +import org.jfree.chart.labels.CustomXYToolTipGenerator; +import org.jfree.chart.plot.XYPlot; +import org.jfree.chart.renderer.xy.XYBarRenderer; +import org.jfree.data.time.SimpleTimePeriod; +import org.jfree.data.time.TimePeriodValues; +import org.jfree.data.time.TimePeriodValuesCollection; + +import java.awt.Color; +import java.util.ArrayList; +import java.util.List; + +public class DisplaySyncPerf extends SyncCommon { + + CustomXYToolTipGenerator mTooltipGenerator; + + List mTooltips[]; + + // The series number for each graphed item. + // sync authorities are 0-3 + private static final int DB_QUERY = 4; + private static final int DB_WRITE = 5; + private static final int HTTP_NETWORK = 6; + private static final int HTTP_PROCESSING = 7; + private static final int NUM_SERIES = (HTTP_PROCESSING + 1); + private static final String SERIES_NAMES[] = {"Calendar", "Gmail", "Feeds", "Contacts", + "DB Query", "DB Write", "HTTP Response", "HTTP Processing",}; + private static final Color SERIES_COLORS[] = {Color.MAGENTA, Color.GREEN, Color.BLUE, + Color.ORANGE, Color.RED, Color.CYAN, Color.PINK, Color.DARK_GRAY}; + private static final double SERIES_YCOORD[] = {0, 0, 0, 0, 1, 1, 2, 2}; + + // Values from data/etc/event-log-tags + private static final int EVENT_DB_OPERATION = 52000; + private static final int EVENT_HTTP_STATS = 52001; + // op types for EVENT_DB_OPERATION + final int EVENT_DB_QUERY = 0; + final int EVENT_DB_WRITE = 1; + + // Information to graph for each authority + private TimePeriodValues mDatasets[]; + + /** + * TimePeriodValuesCollection that supports Y intervals. This allows the + * creation of "floating" bars, rather than bars rooted to the axis. + */ + class YIntervalTimePeriodValuesCollection extends TimePeriodValuesCollection { + /** default serial UID */ + private static final long serialVersionUID = 1L; + + private double yheight; + + /** + * Constructs a collection of bars with a fixed Y height. + * + * @param yheight The height of the bars. + */ + YIntervalTimePeriodValuesCollection(double yheight) { + this.yheight = yheight; + } + + /** + * Returns ending Y value that is a fixed amount greater than the starting value. + * + * @param series the series (zero-based index). + * @param item the item (zero-based index). + * @return The ending Y value for the specified series and item. + */ + @Override + public Number getEndY(int series, int item) { + return getY(series, item).doubleValue() + yheight; + } + } + + /** + * Constructs a graph of network and database stats. + * + * @param name The name of this graph in the graph list. + */ + public DisplaySyncPerf(String name) { + super(name); + } + + /** + * Creates the UI for the event display. + * + * @param parent the parent composite. + * @param logParser the current log parser. + * @return the created control (which may have children). + */ + @Override + public Control createComposite(final Composite parent, EventLogParser logParser, + final ILogColumnListener listener) { + Control composite = createCompositeChart(parent, logParser, "Sync Performance"); + resetUI(); + return composite; + } + + /** + * Resets the display. + */ + @Override + void resetUI() { + super.resetUI(); + XYPlot xyPlot = mChart.getXYPlot(); + xyPlot.getRangeAxis().setVisible(false); + mTooltipGenerator = new CustomXYToolTipGenerator(); + + @SuppressWarnings("unchecked") + List[] mTooltipsTmp = new List[NUM_SERIES]; + mTooltips = mTooltipsTmp; + + XYBarRenderer br = new XYBarRenderer(); + br.setUseYInterval(true); + mDatasets = new TimePeriodValues[NUM_SERIES]; + + TimePeriodValuesCollection tpvc = new YIntervalTimePeriodValuesCollection(1); + xyPlot.setDataset(tpvc); + xyPlot.setRenderer(br); + + for (int i = 0; i < NUM_SERIES; i++) { + br.setSeriesPaint(i, SERIES_COLORS[i]); + mDatasets[i] = new TimePeriodValues(SERIES_NAMES[i]); + tpvc.addSeries(mDatasets[i]); + mTooltips[i] = new ArrayList(); + mTooltipGenerator.addToolTipSeries(mTooltips[i]); + br.setSeriesToolTipGenerator(i, mTooltipGenerator); + } + } + + /** + * Updates the display with a new event. + * + * @param event The event + * @param logParser The parser providing the event. + */ + @Override + void newEvent(EventContainer event, EventLogParser logParser) { + super.newEvent(event, logParser); // Handle sync operation + try { + if (event.mTag == EVENT_DB_OPERATION) { + // 52000 db_operation (name|3),(op_type|1|5),(time|2|3) + String tip = event.getValueAsString(0); + long endTime = event.sec * 1000L + (event.nsec / 1000000L); + int opType = Integer.parseInt(event.getValueAsString(1)); + long duration = Long.parseLong(event.getValueAsString(2)); + + if (opType == EVENT_DB_QUERY) { + mDatasets[DB_QUERY].add(new SimpleTimePeriod(endTime - duration, endTime), + SERIES_YCOORD[DB_QUERY]); + mTooltips[DB_QUERY].add(tip); + } else if (opType == EVENT_DB_WRITE) { + mDatasets[DB_WRITE].add(new SimpleTimePeriod(endTime - duration, endTime), + SERIES_YCOORD[DB_WRITE]); + mTooltips[DB_WRITE].add(tip); + } + } else if (event.mTag == EVENT_HTTP_STATS) { + // 52001 http_stats (useragent|3),(response|2|3),(processing|2|3),(tx|1|2),(rx|1|2) + String tip = event.getValueAsString(0) + ", tx:" + event.getValueAsString(3) + + ", rx: " + event.getValueAsString(4); + long endTime = event.sec * 1000L + (event.nsec / 1000000L); + long netEndTime = endTime - Long.parseLong(event.getValueAsString(2)); + long netStartTime = netEndTime - Long.parseLong(event.getValueAsString(1)); + mDatasets[HTTP_NETWORK].add(new SimpleTimePeriod(netStartTime, netEndTime), + SERIES_YCOORD[HTTP_NETWORK]); + mDatasets[HTTP_PROCESSING].add(new SimpleTimePeriod(netEndTime, endTime), + SERIES_YCOORD[HTTP_PROCESSING]); + mTooltips[HTTP_NETWORK].add(tip); + mTooltips[HTTP_PROCESSING].add(tip); + } + } catch (NumberFormatException e) { + // This can happen when parsing events from froyo+ where the event with id 52000 + // as a completely different format. For now, skip this event if this happens. + } catch (InvalidTypeException e) { + } + } + + /** + * Callback from super.newEvent to process a sync event. + * + * @param event The sync event + * @param startTime Start time (ms) of events + * @param stopTime Stop time (ms) of events + * @param details Details associated with the event. + * @param newEvent True if this event is a new sync event. False if this event + * @param syncSource + */ + @Override + void processSyncEvent(EventContainer event, int auth, long startTime, long stopTime, + String details, boolean newEvent, int syncSource) { + if (newEvent) { + mDatasets[auth].add(new SimpleTimePeriod(startTime, stopTime), SERIES_YCOORD[auth]); + } + } + + /** + * Gets display type + * + * @return display type as an integer + */ + @Override + int getDisplayType() { + return DISPLAY_TYPE_SYNC_PERF; + } +} diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/log/event/EventDisplay.java b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/log/event/EventDisplay.java new file mode 100644 index 00000000..d0d2789f --- /dev/null +++ b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/log/event/EventDisplay.java @@ -0,0 +1,975 @@ +/* + * Copyright (C) 2008 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.ddmuilib.log.event; + +import com.android.ddmlib.Log; +import com.android.ddmlib.log.EventContainer; +import com.android.ddmlib.log.EventContainer.CompareMethod; +import com.android.ddmlib.log.EventContainer.EventValueType; +import com.android.ddmlib.log.EventLogParser; +import com.android.ddmlib.log.EventValueDescription.ValueType; +import com.android.ddmlib.log.InvalidTypeException; + +import org.eclipse.swt.SWT; +import org.eclipse.swt.events.DisposeEvent; +import org.eclipse.swt.events.DisposeListener; +import org.eclipse.swt.graphics.Font; +import org.eclipse.swt.graphics.FontData; +import org.eclipse.swt.widgets.Composite; +import org.eclipse.swt.widgets.Control; +import org.eclipse.swt.widgets.Table; +import org.eclipse.swt.widgets.TableColumn; +import org.jfree.chart.ChartFactory; +import org.jfree.chart.JFreeChart; +import org.jfree.chart.event.ChartChangeEvent; +import org.jfree.chart.event.ChartChangeEventType; +import org.jfree.chart.event.ChartChangeListener; +import org.jfree.chart.plot.XYPlot; +import org.jfree.chart.title.TextTitle; +import org.jfree.data.time.Millisecond; +import org.jfree.data.time.TimeSeries; +import org.jfree.data.time.TimeSeriesCollection; +import org.jfree.experimental.chart.swt.ChartComposite; +import org.jfree.experimental.swt.SWTUtils; + +import java.security.InvalidParameterException; +import java.util.ArrayList; +import java.util.Date; +import java.util.HashMap; +import java.util.Iterator; +import java.util.Set; +import java.util.regex.Pattern; + +/** + * Represents a custom display of one or more events. + */ +abstract class EventDisplay { + + private final static String DISPLAY_DATA_STORAGE_SEPARATOR = ":"; //$NON-NLS-1$ + private final static String PID_STORAGE_SEPARATOR = ","; //$NON-NLS-1$ + private final static String DESCRIPTOR_STORAGE_SEPARATOR = "$"; //$NON-NLS-1$ + private final static String DESCRIPTOR_DATA_STORAGE_SEPARATOR = "!"; //$NON-NLS-1$ + + private final static String FILTER_VALUE_NULL = ""; //$NON-NLS-1$ + + public final static int DISPLAY_TYPE_LOG_ALL = 0; + public final static int DISPLAY_TYPE_FILTERED_LOG = 1; + public final static int DISPLAY_TYPE_GRAPH = 2; + public final static int DISPLAY_TYPE_SYNC = 3; + public final static int DISPLAY_TYPE_SYNC_HIST = 4; + public final static int DISPLAY_TYPE_SYNC_PERF = 5; + + private final static int EVENT_CHECK_FAILED = 0; + protected final static int EVENT_CHECK_SAME_TAG = 1; + protected final static int EVENT_CHECK_SAME_VALUE = 2; + + /** + * Creates the appropriate EventDisplay subclass. + * + * @param type the type of display (DISPLAY_TYPE_LOG_ALL, etc) + * @param name the name of the display + * @return the created object + */ + public static EventDisplay eventDisplayFactory(int type, String name) { + switch (type) { + case DISPLAY_TYPE_LOG_ALL: + return new DisplayLog(name); + case DISPLAY_TYPE_FILTERED_LOG: + return new DisplayFilteredLog(name); + case DISPLAY_TYPE_SYNC: + return new DisplaySync(name); + case DISPLAY_TYPE_SYNC_HIST: + return new DisplaySyncHistogram(name); + case DISPLAY_TYPE_GRAPH: + return new DisplayGraph(name); + case DISPLAY_TYPE_SYNC_PERF: + return new DisplaySyncPerf(name); + default: + throw new InvalidParameterException("Unknown Display Type " + type); //$NON-NLS-1$ + } + } + + /** + * Adds event to the display. + * @param event The event + * @param logParser The log parser. + */ + abstract void newEvent(EventContainer event, EventLogParser logParser); + + /** + * Resets the display. + */ + abstract void resetUI(); + + /** + * Gets display type + * + * @return display type as an integer + */ + abstract int getDisplayType(); + + /** + * Creates the UI for the event display. + * + * @param parent the parent composite. + * @param logParser the current log parser. + * @return the created control (which may have children). + */ + abstract Control createComposite(final Composite parent, EventLogParser logParser, + final ILogColumnListener listener); + + interface ILogColumnListener { + void columnResized(int index, TableColumn sourceColumn); + } + + /** + * Describes an event to be displayed. + */ + static class OccurrenceDisplayDescriptor { + + int eventTag = -1; + int seriesValueIndex = -1; + boolean includePid = false; + int filterValueIndex = -1; + CompareMethod filterCompareMethod = CompareMethod.EQUAL_TO; + Object filterValue = null; + + OccurrenceDisplayDescriptor() { + } + + OccurrenceDisplayDescriptor(OccurrenceDisplayDescriptor descriptor) { + replaceWith(descriptor); + } + + OccurrenceDisplayDescriptor(int eventTag) { + this.eventTag = eventTag; + } + + OccurrenceDisplayDescriptor(int eventTag, int seriesValueIndex) { + this.eventTag = eventTag; + this.seriesValueIndex = seriesValueIndex; + } + + void replaceWith(OccurrenceDisplayDescriptor descriptor) { + eventTag = descriptor.eventTag; + seriesValueIndex = descriptor.seriesValueIndex; + includePid = descriptor.includePid; + filterValueIndex = descriptor.filterValueIndex; + filterCompareMethod = descriptor.filterCompareMethod; + filterValue = descriptor.filterValue; + } + + /** + * Loads the descriptor parameter from a storage string. The storage string must have + * been generated with {@link #getStorageString()}. + * + * @param storageString the storage string + */ + final void loadFrom(String storageString) { + String[] values = storageString.split(Pattern.quote(DESCRIPTOR_DATA_STORAGE_SEPARATOR)); + loadFrom(values, 0); + } + + /** + * Loads the parameters from an array of strings. + * + * @param storageStrings the strings representing each parameter. + * @param index the starting index in the array of strings. + * @return the new index in the array. + */ + protected int loadFrom(String[] storageStrings, int index) { + eventTag = Integer.parseInt(storageStrings[index++]); + seriesValueIndex = Integer.parseInt(storageStrings[index++]); + includePid = Boolean.parseBoolean(storageStrings[index++]); + filterValueIndex = Integer.parseInt(storageStrings[index++]); + try { + filterCompareMethod = CompareMethod.valueOf(storageStrings[index++]); + } catch (IllegalArgumentException e) { + // if the name does not match any known CompareMethod, we init it to the default one + filterCompareMethod = CompareMethod.EQUAL_TO; + } + String value = storageStrings[index++]; + if (filterValueIndex != -1 && FILTER_VALUE_NULL.equals(value) == false) { + filterValue = EventValueType.getObjectFromStorageString(value); + } + + return index; + } + + /** + * Returns the storage string for the receiver. + */ + String getStorageString() { + StringBuilder sb = new StringBuilder(); + sb.append(eventTag); + sb.append(DESCRIPTOR_DATA_STORAGE_SEPARATOR); + sb.append(seriesValueIndex); + sb.append(DESCRIPTOR_DATA_STORAGE_SEPARATOR); + sb.append(Boolean.toString(includePid)); + sb.append(DESCRIPTOR_DATA_STORAGE_SEPARATOR); + sb.append(filterValueIndex); + sb.append(DESCRIPTOR_DATA_STORAGE_SEPARATOR); + sb.append(filterCompareMethod.name()); + sb.append(DESCRIPTOR_DATA_STORAGE_SEPARATOR); + if (filterValue != null) { + String value = EventValueType.getStorageString(filterValue); + if (value != null) { + sb.append(value); + } else { + sb.append(FILTER_VALUE_NULL); + } + } else { + sb.append(FILTER_VALUE_NULL); + } + + return sb.toString(); + } + } + + /** + * Describes an event value to be displayed. + */ + static final class ValueDisplayDescriptor extends OccurrenceDisplayDescriptor { + String valueName; + int valueIndex = -1; + + ValueDisplayDescriptor() { + super(); + } + + ValueDisplayDescriptor(ValueDisplayDescriptor descriptor) { + super(); + replaceWith(descriptor); + } + + ValueDisplayDescriptor(int eventTag, String valueName, int valueIndex) { + super(eventTag); + this.valueName = valueName; + this.valueIndex = valueIndex; + } + + ValueDisplayDescriptor(int eventTag, String valueName, int valueIndex, + int seriesValueIndex) { + super(eventTag, seriesValueIndex); + this.valueName = valueName; + this.valueIndex = valueIndex; + } + + @Override + void replaceWith(OccurrenceDisplayDescriptor descriptor) { + super.replaceWith(descriptor); + if (descriptor instanceof ValueDisplayDescriptor) { + ValueDisplayDescriptor valueDescriptor = (ValueDisplayDescriptor) descriptor; + valueName = valueDescriptor.valueName; + valueIndex = valueDescriptor.valueIndex; + } + } + + /** + * Loads the parameters from an array of strings. + * + * @param storageStrings the strings representing each parameter. + * @param index the starting index in the array of strings. + * @return the new index in the array. + */ + @Override + protected int loadFrom(String[] storageStrings, int index) { + index = super.loadFrom(storageStrings, index); + valueName = storageStrings[index++]; + valueIndex = Integer.parseInt(storageStrings[index++]); + return index; + } + + /** + * Returns the storage string for the receiver. + */ + @Override + String getStorageString() { + String superStorage = super.getStorageString(); + + StringBuilder sb = new StringBuilder(); + sb.append(superStorage); + sb.append(DESCRIPTOR_DATA_STORAGE_SEPARATOR); + sb.append(valueName); + sb.append(DESCRIPTOR_DATA_STORAGE_SEPARATOR); + sb.append(valueIndex); + + return sb.toString(); + } + } + + /* ================== + * Event Display parameters. + * ================== */ + protected String mName; + + private boolean mPidFiltering = false; + + private ArrayList mPidFilterList = null; + + protected final ArrayList mValueDescriptors = + new ArrayList(); + private final ArrayList mOccurrenceDescriptors = + new ArrayList(); + + /* ================== + * Event Display members for display purpose. + * ================== */ + // chart objects + /** + * This is a map of (descriptor, map2) where map2 is a map of (pid, chart-series) + */ + protected final HashMap> mValueDescriptorSeriesMap = + new HashMap>(); + /** + * This is a map of (descriptor, map2) where map2 is a map of (pid, chart-series) + */ + protected final HashMap> mOcurrenceDescriptorSeriesMap = + new HashMap>(); + + /** + * This is a map of (ValueType, dataset) + */ + protected final HashMap mValueTypeDataSetMap = + new HashMap(); + + protected JFreeChart mChart; + protected TimeSeriesCollection mOccurrenceDataSet; + protected int mDataSetCount; + private ChartComposite mChartComposite; + protected long mMaximumChartItemAge = -1; + protected long mHistWidth = 1; + + // log objects. + protected Table mLogTable; + + /* ================== + * Misc data. + * ================== */ + protected int mValueDescriptorCheck = EVENT_CHECK_FAILED; + + EventDisplay(String name) { + mName = name; + } + + static EventDisplay clone(EventDisplay from) { + EventDisplay ed = eventDisplayFactory(from.getDisplayType(), from.getName()); + ed.mName = from.mName; + ed.mPidFiltering = from.mPidFiltering; + ed.mMaximumChartItemAge = from.mMaximumChartItemAge; + ed.mHistWidth = from.mHistWidth; + + if (from.mPidFilterList != null) { + ed.mPidFilterList = new ArrayList(); + ed.mPidFilterList.addAll(from.mPidFilterList); + } + + for (ValueDisplayDescriptor desc : from.mValueDescriptors) { + ed.mValueDescriptors.add(new ValueDisplayDescriptor(desc)); + } + ed.mValueDescriptorCheck = from.mValueDescriptorCheck; + + for (OccurrenceDisplayDescriptor desc : from.mOccurrenceDescriptors) { + ed.mOccurrenceDescriptors.add(new OccurrenceDisplayDescriptor(desc)); + } + return ed; + } + + /** + * Returns the parameters of the receiver as a single String for storage. + */ + String getStorageString() { + StringBuilder sb = new StringBuilder(); + + sb.append(mName); + sb.append(DISPLAY_DATA_STORAGE_SEPARATOR); + sb.append(getDisplayType()); + sb.append(DISPLAY_DATA_STORAGE_SEPARATOR); + sb.append(Boolean.toString(mPidFiltering)); + sb.append(DISPLAY_DATA_STORAGE_SEPARATOR); + sb.append(getPidStorageString()); + sb.append(DISPLAY_DATA_STORAGE_SEPARATOR); + sb.append(getDescriptorStorageString(mValueDescriptors)); + sb.append(DISPLAY_DATA_STORAGE_SEPARATOR); + sb.append(getDescriptorStorageString(mOccurrenceDescriptors)); + sb.append(DISPLAY_DATA_STORAGE_SEPARATOR); + sb.append(mMaximumChartItemAge); + sb.append(DISPLAY_DATA_STORAGE_SEPARATOR); + sb.append(mHistWidth); + sb.append(DISPLAY_DATA_STORAGE_SEPARATOR); + + return sb.toString(); + } + + void setName(String name) { + mName = name; + } + + String getName() { + return mName; + } + + void setPidFiltering(boolean filterByPid) { + mPidFiltering = filterByPid; + } + + boolean getPidFiltering() { + return mPidFiltering; + } + + void setPidFilterList(ArrayList pids) { + if (mPidFiltering == false) { + throw new InvalidParameterException(); + } + + mPidFilterList = pids; + } + + ArrayList getPidFilterList() { + return mPidFilterList; + } + + void addPidFiler(int pid) { + if (mPidFiltering == false) { + throw new InvalidParameterException(); + } + + if (mPidFilterList == null) { + mPidFilterList = new ArrayList(); + } + + mPidFilterList.add(pid); + } + + /** + * Returns an iterator to the list of {@link ValueDisplayDescriptor}. + */ + Iterator getValueDescriptors() { + return mValueDescriptors.iterator(); + } + + /** + * Update checks on the descriptors. Must be called whenever a descriptor is modified outside + * of this class. + */ + void updateValueDescriptorCheck() { + mValueDescriptorCheck = checkDescriptors(); + } + + /** + * Returns an iterator to the list of {@link OccurrenceDisplayDescriptor}. + */ + Iterator getOccurrenceDescriptors() { + return mOccurrenceDescriptors.iterator(); + } + + /** + * Adds a descriptor. This can be a {@link OccurrenceDisplayDescriptor} or a + * {@link ValueDisplayDescriptor}. + * + * @param descriptor the descriptor to be added. + */ + void addDescriptor(OccurrenceDisplayDescriptor descriptor) { + if (descriptor instanceof ValueDisplayDescriptor) { + mValueDescriptors.add((ValueDisplayDescriptor) descriptor); + mValueDescriptorCheck = checkDescriptors(); + } else { + mOccurrenceDescriptors.add(descriptor); + } + } + + /** + * Returns a descriptor by index and class (extending {@link OccurrenceDisplayDescriptor}). + * + * @param descriptorClass the class of the descriptor to return. + * @param index the index of the descriptor to return. + * @return either a {@link OccurrenceDisplayDescriptor} or a {@link ValueDisplayDescriptor} + * or null if descriptorClass is another class. + */ + OccurrenceDisplayDescriptor getDescriptor( + Class descriptorClass, int index) { + + if (descriptorClass == OccurrenceDisplayDescriptor.class) { + return mOccurrenceDescriptors.get(index); + } else if (descriptorClass == ValueDisplayDescriptor.class) { + return mValueDescriptors.get(index); + } + + return null; + } + + /** + * Removes a descriptor based on its class and index. + * + * @param descriptorClass the class of the descriptor. + * @param index the index of the descriptor to be removed. + */ + void removeDescriptor(Class descriptorClass, int index) { + if (descriptorClass == OccurrenceDisplayDescriptor.class) { + mOccurrenceDescriptors.remove(index); + } else if (descriptorClass == ValueDisplayDescriptor.class) { + mValueDescriptors.remove(index); + mValueDescriptorCheck = checkDescriptors(); + } + } + + Control createCompositeChart(final Composite parent, EventLogParser logParser, + String title) { + mChart = ChartFactory.createTimeSeriesChart( + null, + null /* timeAxisLabel */, + null /* valueAxisLabel */, + null, /* dataset. set below */ + true /* legend */, + false /* tooltips */, + false /* urls */); + + // get the font to make a proper title. We need to convert the swt font, + // into an awt font. + Font f = parent.getFont(); + FontData[] fData = f.getFontData(); + + // event though on Mac OS there could be more than one fontData, we'll only use + // the first one. + FontData firstFontData = fData[0]; + + java.awt.Font awtFont = SWTUtils.toAwtFont(parent.getDisplay(), + firstFontData, true /* ensureSameSize */); + + + mChart.setTitle(new TextTitle(title, awtFont)); + + final XYPlot xyPlot = mChart.getXYPlot(); + xyPlot.setRangeCrosshairVisible(true); + xyPlot.setRangeCrosshairLockedOnData(true); + xyPlot.setDomainCrosshairVisible(true); + xyPlot.setDomainCrosshairLockedOnData(true); + + mChart.addChangeListener(new ChartChangeListener() { + @Override + public void chartChanged(ChartChangeEvent event) { + ChartChangeEventType type = event.getType(); + if (type == ChartChangeEventType.GENERAL) { + // because the value we need (rangeCrosshair and domainCrosshair) are + // updated on the draw, but the notification happens before the draw, + // we process the click in a future runnable! + parent.getDisplay().asyncExec(new Runnable() { + @Override + public void run() { + processClick(xyPlot); + } + }); + } + } + }); + + mChartComposite = new ChartComposite(parent, SWT.BORDER, mChart, + ChartComposite.DEFAULT_WIDTH, + ChartComposite.DEFAULT_HEIGHT, + ChartComposite.DEFAULT_MINIMUM_DRAW_WIDTH, + ChartComposite.DEFAULT_MINIMUM_DRAW_HEIGHT, + 3000, // max draw width. We don't want it to zoom, so we put a big number + 3000, // max draw height. We don't want it to zoom, so we put a big number + true, // off-screen buffer + true, // properties + true, // save + true, // print + true, // zoom + true); // tooltips + + mChartComposite.addDisposeListener(new DisposeListener() { + @Override + public void widgetDisposed(DisposeEvent e) { + mValueTypeDataSetMap.clear(); + mDataSetCount = 0; + mOccurrenceDataSet = null; + mChart = null; + mChartComposite = null; + mValueDescriptorSeriesMap.clear(); + mOcurrenceDescriptorSeriesMap.clear(); + } + }); + + return mChartComposite; + + } + + private void processClick(XYPlot xyPlot) { + double rangeValue = xyPlot.getRangeCrosshairValue(); + if (rangeValue != 0) { + double domainValue = xyPlot.getDomainCrosshairValue(); + + Millisecond msec = new Millisecond(new Date((long) domainValue)); + + // look for values in the dataset that contains data at this TimePeriod + Set descKeys = mValueDescriptorSeriesMap.keySet(); + + for (ValueDisplayDescriptor descKey : descKeys) { + HashMap map = mValueDescriptorSeriesMap.get(descKey); + + Set pidKeys = map.keySet(); + + for (Integer pidKey : pidKeys) { + TimeSeries series = map.get(pidKey); + + Number value = series.getValue(msec); + if (value != null) { + // found a match. lets check against the actual value. + if (value.doubleValue() == rangeValue) { + + return; + } + } + } + } + } + } + + + /** + * Resizes the index-th column of the log {@link Table} (if applicable). + * Subclasses can override if necessary. + *

+ * This does nothing if the Table object is null (because the display + * type does not use a column) or if the index-th column is in fact the originating + * column passed as argument. + * + * @param index the index of the column to resize + * @param sourceColumn the original column that was resize, and on which we need to sync the + * index-th column width. + */ + void resizeColumn(int index, TableColumn sourceColumn) { + } + + /** + * Sets the current {@link EventLogParser} object. + * Subclasses can override if necessary. + */ + protected void setNewLogParser(EventLogParser logParser) { + } + + /** + * Prepares the {@link EventDisplay} for a multi event display. + */ + void startMultiEventDisplay() { + if (mLogTable != null) { + mLogTable.setRedraw(false); + } + } + + /** + * Finalizes the {@link EventDisplay} after a multi event display. + */ + void endMultiEventDisplay() { + if (mLogTable != null) { + mLogTable.setRedraw(true); + } + } + + /** + * Returns the {@link Table} object used to display events, if any. + * + * @return a Table object or null. + */ + Table getTable() { + return mLogTable; + } + + /** + * Loads a new {@link EventDisplay} from a storage string. The string must have been created + * with {@link #getStorageString()}. + * + * @param storageString the storage string + * @return a new {@link EventDisplay} or null if the load failed. + */ + static EventDisplay load(String storageString) { + if (storageString.length() > 0) { + // the storage string is separated by ':' + String[] values = storageString.split(Pattern.quote(DISPLAY_DATA_STORAGE_SEPARATOR)); + + try { + int index = 0; + + String name = values[index++]; + int displayType = Integer.parseInt(values[index++]); + boolean pidFiltering = Boolean.parseBoolean(values[index++]); + + EventDisplay ed = eventDisplayFactory(displayType, name); + ed.setPidFiltering(pidFiltering); + + // because empty sections are removed by String.split(), we have to check + // the index for those. + if (index < values.length) { + ed.loadPidFilters(values[index++]); + } + + if (index < values.length) { + ed.loadValueDescriptors(values[index++]); + } + + if (index < values.length) { + ed.loadOccurrenceDescriptors(values[index++]); + } + + ed.updateValueDescriptorCheck(); + + if (index < values.length) { + ed.mMaximumChartItemAge = Long.parseLong(values[index++]); + } + + if (index < values.length) { + ed.mHistWidth = Long.parseLong(values[index++]); + } + + return ed; + } catch (RuntimeException re) { + // we'll return null below. + Log.e("ddms", re); + } + } + + return null; + } + + private String getPidStorageString() { + if (mPidFilterList != null) { + StringBuilder sb = new StringBuilder(); + boolean first = true; + for (Integer i : mPidFilterList) { + if (first == false) { + sb.append(PID_STORAGE_SEPARATOR); + } else { + first = false; + } + sb.append(i); + } + + return sb.toString(); + } + return ""; //$NON-NLS-1$ + } + + + private void loadPidFilters(String storageString) { + if (storageString.length() > 0) { + String[] values = storageString.split(Pattern.quote(PID_STORAGE_SEPARATOR)); + + for (String value : values) { + if (mPidFilterList == null) { + mPidFilterList = new ArrayList(); + } + mPidFilterList.add(Integer.parseInt(value)); + } + } + } + + private String getDescriptorStorageString( + ArrayList descriptorList) { + StringBuilder sb = new StringBuilder(); + boolean first = true; + + for (OccurrenceDisplayDescriptor descriptor : descriptorList) { + if (first == false) { + sb.append(DESCRIPTOR_STORAGE_SEPARATOR); + } else { + first = false; + } + sb.append(descriptor.getStorageString()); + } + + return sb.toString(); + } + + private void loadOccurrenceDescriptors(String storageString) { + if (storageString.length() == 0) { + return; + } + + String[] values = storageString.split(Pattern.quote(DESCRIPTOR_STORAGE_SEPARATOR)); + + for (String value : values) { + OccurrenceDisplayDescriptor desc = new OccurrenceDisplayDescriptor(); + desc.loadFrom(value); + mOccurrenceDescriptors.add(desc); + } + } + + private void loadValueDescriptors(String storageString) { + if (storageString.length() == 0) { + return; + } + + String[] values = storageString.split(Pattern.quote(DESCRIPTOR_STORAGE_SEPARATOR)); + + for (String value : values) { + ValueDisplayDescriptor desc = new ValueDisplayDescriptor(); + desc.loadFrom(value); + mValueDescriptors.add(desc); + } + } + + /** + * Fills a list with {@link OccurrenceDisplayDescriptor} (or a subclass of it) from another + * list if they are configured to display the {@link EventContainer} + * + * @param event the event container + * @param fullList the list with all the descriptors. + * @param outList the list to fill. + */ + @SuppressWarnings("unchecked") + private void getDescriptors(EventContainer event, + ArrayList fullList, + ArrayList outList) { + for (OccurrenceDisplayDescriptor descriptor : fullList) { + try { + // first check the event tag. + if (descriptor.eventTag == event.mTag) { + // now check if we have a filter on a value + if (descriptor.filterValueIndex == -1 || + event.testValue(descriptor.filterValueIndex, descriptor.filterValue, + descriptor.filterCompareMethod)) { + outList.add(descriptor); + } + } + } catch (InvalidTypeException ite) { + // if the filter for the descriptor was incorrect, we ignore the descriptor. + } catch (ArrayIndexOutOfBoundsException aioobe) { + // if the index was wrong (the event content may have changed since we setup the + // display), we do nothing but log the error + Log.e("Event Log", String.format( + "ArrayIndexOutOfBoundsException occured when checking %1$d-th value of event %2$d", //$NON-NLS-1$ + descriptor.filterValueIndex, descriptor.eventTag)); + } + } + } + + /** + * Filters the {@link com.android.ddmlib.log.EventContainer}, and fills two list of {@link com.android.ddmuilib.log.event.EventDisplay.ValueDisplayDescriptor} + * and {@link com.android.ddmuilib.log.event.EventDisplay.OccurrenceDisplayDescriptor} configured to display the event. + * + * @param event + * @param valueDescriptors + * @param occurrenceDescriptors + * @return true if the event should be displayed. + */ + + protected boolean filterEvent(EventContainer event, + ArrayList valueDescriptors, + ArrayList occurrenceDescriptors) { + + // test the pid first (if needed) + if (mPidFiltering && mPidFilterList != null) { + boolean found = false; + for (int pid : mPidFilterList) { + if (pid == event.pid) { + found = true; + break; + } + } + + if (found == false) { + return false; + } + } + + // now get the list of matching descriptors + getDescriptors(event, mValueDescriptors, valueDescriptors); + getDescriptors(event, mOccurrenceDescriptors, occurrenceDescriptors); + + // and return whether there is at least one match in either list. + return (valueDescriptors.size() > 0 || occurrenceDescriptors.size() > 0); + } + + /** + * Checks all the {@link ValueDisplayDescriptor} for similarity. + * If all the event values are from the same tag, the method will return EVENT_CHECK_SAME_TAG. + * If all the event/value are the same, the method will return EVENT_CHECK_SAME_VALUE + * + * @return flag as described above + */ + private int checkDescriptors() { + if (mValueDescriptors.size() < 2) { + return EVENT_CHECK_SAME_VALUE; + } + + int tag = -1; + int index = -1; + for (ValueDisplayDescriptor display : mValueDescriptors) { + if (tag == -1) { + tag = display.eventTag; + index = display.valueIndex; + } else { + if (tag != display.eventTag) { + return EVENT_CHECK_FAILED; + } else { + if (index != -1) { + if (index != display.valueIndex) { + index = -1; + } + } + } + } + } + + if (index == -1) { + return EVENT_CHECK_SAME_TAG; + } + + return EVENT_CHECK_SAME_VALUE; + } + + /** + * Resets the time limit on the chart to be infinite. + */ + void resetChartTimeLimit() { + mMaximumChartItemAge = -1; + } + + /** + * Sets the time limit on the charts. + * + * @param timeLimit the time limit in seconds. + */ + void setChartTimeLimit(long timeLimit) { + mMaximumChartItemAge = timeLimit; + } + + long getChartTimeLimit() { + return mMaximumChartItemAge; + } + + /** + * m + * Resets the histogram width + */ + void resetHistWidth() { + mHistWidth = 1; + } + + /** + * Sets the histogram width + * + * @param histWidth the width in hours + */ + void setHistWidth(long histWidth) { + mHistWidth = histWidth; + } + + long getHistWidth() { + return mHistWidth; + } +} diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/log/event/EventDisplayOptions.java b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/log/event/EventDisplayOptions.java new file mode 100644 index 00000000..b13f3f49 --- /dev/null +++ b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/log/event/EventDisplayOptions.java @@ -0,0 +1,961 @@ +/* + * Copyright (C) 2008 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.ddmuilib.log.event; + +import com.android.ddmlib.log.EventContainer; +import com.android.ddmlib.log.EventLogParser; +import com.android.ddmlib.log.EventValueDescription; +import com.android.ddmuilib.DdmUiPreferences; +import com.android.ddmuilib.ImageLoader; +import com.android.ddmuilib.log.event.EventDisplay.OccurrenceDisplayDescriptor; +import com.android.ddmuilib.log.event.EventDisplay.ValueDisplayDescriptor; + +import org.eclipse.jface.preference.IPreferenceStore; +import org.eclipse.swt.SWT; +import org.eclipse.swt.events.ModifyEvent; +import org.eclipse.swt.events.ModifyListener; +import org.eclipse.swt.events.SelectionAdapter; +import org.eclipse.swt.events.SelectionEvent; +import org.eclipse.swt.graphics.Rectangle; +import org.eclipse.swt.layout.GridData; +import org.eclipse.swt.layout.GridLayout; +import org.eclipse.swt.widgets.Button; +import org.eclipse.swt.widgets.Combo; +import org.eclipse.swt.widgets.Composite; +import org.eclipse.swt.widgets.Dialog; +import org.eclipse.swt.widgets.Display; +import org.eclipse.swt.widgets.Event; +import org.eclipse.swt.widgets.Group; +import org.eclipse.swt.widgets.Label; +import org.eclipse.swt.widgets.List; +import org.eclipse.swt.widgets.Listener; +import org.eclipse.swt.widgets.Shell; +import org.eclipse.swt.widgets.Text; + +import java.util.ArrayList; +import java.util.Iterator; +import java.util.Map; + +class EventDisplayOptions extends Dialog { + private static final int DLG_WIDTH = 700; + private static final int DLG_HEIGHT = 700; + + private Shell mParent; + private Shell mShell; + + private boolean mEditStatus = false; + private final ArrayList mDisplayList = new ArrayList(); + + /* LEFT LIST */ + private List mEventDisplayList; + private Button mEventDisplayNewButton; + private Button mEventDisplayDeleteButton; + private Button mEventDisplayUpButton; + private Button mEventDisplayDownButton; + private Text mDisplayWidthText; + private Text mDisplayHeightText; + + /* WIDGETS ON THE RIGHT */ + private Text mDisplayNameText; + private Combo mDisplayTypeCombo; + private Group mChartOptions; + private Group mHistOptions; + private Button mPidFilterCheckBox; + private Text mPidText; + + /** Map with (event-tag, event name) */ + private Map mEventTagMap; + + /** Map with (event-tag, array of value info for the event) */ + private Map mEventDescriptionMap; + + /** list of current pids */ + private ArrayList mPidList; + + private EventLogParser mLogParser; + + private Group mInfoGroup; + + private static class SelectionWidgets { + private List mList; + private Button mNewButton; + private Button mEditButton; + private Button mDeleteButton; + + private void setEnabled(boolean enable) { + mList.setEnabled(enable); + mNewButton.setEnabled(enable); + mEditButton.setEnabled(enable); + mDeleteButton.setEnabled(enable); + } + } + + private SelectionWidgets mValueSelection; + private SelectionWidgets mOccurrenceSelection; + + /** flag to temporarly disable processing of {@link Text} changes, so that + * {@link Text#setText(String)} can be called safely. */ + private boolean mProcessTextChanges = true; + private Text mTimeLimitText; + private Text mHistWidthText; + + EventDisplayOptions(Shell parent) { + super(parent, SWT.DIALOG_TRIM | SWT.BORDER | SWT.APPLICATION_MODAL); + } + + /** + * Opens the display option dialog, to edit the {@link EventDisplay} objects provided in the + * list. + * @param logParser + * @param displayList + * @param eventList + * @return true if the list of {@link EventDisplay} objects was updated. + */ + boolean open(EventLogParser logParser, ArrayList displayList, + ArrayList eventList) { + mLogParser = logParser; + + if (logParser != null) { + // we need 2 things from the parser. + // the event tag / event name map + mEventTagMap = logParser.getTagMap(); + + // the event info map + mEventDescriptionMap = logParser.getEventInfoMap(); + } + + // make a copy of the EventDisplay list since we'll use working copies. + duplicateEventDisplay(displayList); + + // build a list of pid from the list of events. + buildPidList(eventList); + + createUI(); + + if (mParent == null || mShell == null) { + return false; + } + + // Set the dialog size. + mShell.setMinimumSize(DLG_WIDTH, DLG_HEIGHT); + Rectangle r = mParent.getBounds(); + // get the center new top left. + int cx = r.x + r.width/2; + int x = cx - DLG_WIDTH / 2; + int cy = r.y + r.height/2; + int y = cy - DLG_HEIGHT / 2; + mShell.setBounds(x, y, DLG_WIDTH, DLG_HEIGHT); + + mShell.layout(); + + // actually open the dialog + mShell.open(); + + // event loop until the dialog is closed. + Display display = mParent.getDisplay(); + while (!mShell.isDisposed()) { + if (!display.readAndDispatch()) + display.sleep(); + } + + return mEditStatus; + } + + ArrayList getEventDisplays() { + return mDisplayList; + } + + private void createUI() { + mParent = getParent(); + mShell = new Shell(mParent, getStyle()); + mShell.setText("Event Display Configuration"); + + mShell.setLayout(new GridLayout(1, true)); + + final Composite topPanel = new Composite(mShell, SWT.NONE); + topPanel.setLayoutData(new GridData(GridData.FILL_BOTH)); + topPanel.setLayout(new GridLayout(2, false)); + + // create the tree on the left and the controls on the right. + Composite leftPanel = new Composite(topPanel, SWT.NONE); + Composite rightPanel = new Composite(topPanel, SWT.NONE); + + createLeftPanel(leftPanel); + createRightPanel(rightPanel); + + mShell.addListener(SWT.Close, new Listener() { + @Override + public void handleEvent(Event event) { + event.doit = true; + } + }); + + Label separator = new Label(mShell, SWT.SEPARATOR | SWT.HORIZONTAL); + separator.setLayoutData(new GridData(GridData.FILL_HORIZONTAL)); + + Composite bottomButtons = new Composite(mShell, SWT.NONE); + bottomButtons.setLayoutData(new GridData(GridData.FILL_HORIZONTAL)); + GridLayout gl; + bottomButtons.setLayout(gl = new GridLayout(2, true)); + gl.marginHeight = gl.marginWidth = 0; + + Button okButton = new Button(bottomButtons, SWT.PUSH); + okButton.setText("OK"); + okButton.addSelectionListener(new SelectionAdapter() { + /* (non-Javadoc) + * @see org.eclipse.swt.events.SelectionAdapter#widgetSelected(org.eclipse.swt.events.SelectionEvent) + */ + @Override + public void widgetSelected(SelectionEvent e) { + mShell.close(); + } + }); + + Button cancelButton = new Button(bottomButtons, SWT.PUSH); + cancelButton.setText("Cancel"); + cancelButton.addSelectionListener(new SelectionAdapter() { + /* (non-Javadoc) + * @see org.eclipse.swt.events.SelectionAdapter#widgetSelected(org.eclipse.swt.events.SelectionEvent) + */ + @Override + public void widgetSelected(SelectionEvent e) { + // cancel the modification flag. + mEditStatus = false; + + // and close + mShell.close(); + } + }); + + enable(false); + + // fill the list with the current display + fillEventDisplayList(); + } + + private void createLeftPanel(Composite leftPanel) { + final IPreferenceStore store = DdmUiPreferences.getStore(); + + GridLayout gl; + + leftPanel.setLayoutData(new GridData(GridData.FILL_VERTICAL)); + leftPanel.setLayout(gl = new GridLayout(1, false)); + gl.verticalSpacing = 1; + + mEventDisplayList = new List(leftPanel, + SWT.BORDER | SWT.SINGLE | SWT.V_SCROLL | SWT.FULL_SELECTION); + mEventDisplayList.setLayoutData(new GridData(GridData.FILL_BOTH)); + mEventDisplayList.addSelectionListener(new SelectionAdapter() { + @Override + public void widgetSelected(SelectionEvent e) { + handleEventDisplaySelection(); + } + }); + + Composite bottomControls = new Composite(leftPanel, SWT.NONE); + bottomControls.setLayoutData(new GridData(GridData.FILL_HORIZONTAL)); + bottomControls.setLayout(gl = new GridLayout(5, false)); + gl.marginHeight = gl.marginWidth = 0; + gl.verticalSpacing = 0; + gl.horizontalSpacing = 0; + + ImageLoader loader = ImageLoader.getDdmUiLibLoader(); + mEventDisplayNewButton = new Button(bottomControls, SWT.PUSH | SWT.FLAT); + mEventDisplayNewButton.setImage(loader.loadImage("add.png", //$NON-NLS-1$ + leftPanel.getDisplay())); + mEventDisplayNewButton.setToolTipText("Adds a new event display"); + mEventDisplayNewButton.setLayoutData(new GridData(GridData.HORIZONTAL_ALIGN_CENTER)); + mEventDisplayNewButton.addSelectionListener(new SelectionAdapter() { + @Override + public void widgetSelected(SelectionEvent e) { + createNewEventDisplay(); + } + }); + + mEventDisplayDeleteButton = new Button(bottomControls, SWT.PUSH | SWT.FLAT); + mEventDisplayDeleteButton.setImage(loader.loadImage("delete.png", //$NON-NLS-1$ + leftPanel.getDisplay())); + mEventDisplayDeleteButton.setToolTipText("Deletes the selected event display"); + mEventDisplayDeleteButton.setLayoutData(new GridData(GridData.HORIZONTAL_ALIGN_CENTER)); + mEventDisplayDeleteButton.addSelectionListener(new SelectionAdapter() { + @Override + public void widgetSelected(SelectionEvent e) { + deleteEventDisplay(); + } + }); + + mEventDisplayUpButton = new Button(bottomControls, SWT.PUSH | SWT.FLAT); + mEventDisplayUpButton.setImage(loader.loadImage("up.png", //$NON-NLS-1$ + leftPanel.getDisplay())); + mEventDisplayUpButton.setToolTipText("Moves the selected event display up"); + mEventDisplayUpButton.addSelectionListener(new SelectionAdapter() { + @Override + public void widgetSelected(SelectionEvent e) { + // get current selection. + int selection = mEventDisplayList.getSelectionIndex(); + if (selection > 0) { + // update the list of EventDisplay. + EventDisplay display = mDisplayList.remove(selection); + mDisplayList.add(selection - 1, display); + + // update the list widget + mEventDisplayList.remove(selection); + mEventDisplayList.add(display.getName(), selection - 1); + + // update the selection and reset the ui. + mEventDisplayList.select(selection - 1); + handleEventDisplaySelection(); + mEventDisplayList.showSelection(); + + setModified(); + } + } + }); + + mEventDisplayDownButton = new Button(bottomControls, SWT.PUSH | SWT.FLAT); + mEventDisplayDownButton.setImage(loader.loadImage("down.png", //$NON-NLS-1$ + leftPanel.getDisplay())); + mEventDisplayDownButton.setToolTipText("Moves the selected event display down"); + mEventDisplayDownButton.addSelectionListener(new SelectionAdapter() { + @Override + public void widgetSelected(SelectionEvent e) { + // get current selection. + int selection = mEventDisplayList.getSelectionIndex(); + if (selection != -1 && selection < mEventDisplayList.getItemCount() - 1) { + // update the list of EventDisplay. + EventDisplay display = mDisplayList.remove(selection); + mDisplayList.add(selection + 1, display); + + // update the list widget + mEventDisplayList.remove(selection); + mEventDisplayList.add(display.getName(), selection + 1); + + // update the selection and reset the ui. + mEventDisplayList.select(selection + 1); + handleEventDisplaySelection(); + mEventDisplayList.showSelection(); + + setModified(); + } + } + }); + + Group sizeGroup = new Group(leftPanel, SWT.NONE); + sizeGroup.setText("Display Size:"); + sizeGroup.setLayoutData(new GridData(GridData.FILL_HORIZONTAL)); + sizeGroup.setLayout(new GridLayout(2, false)); + + Label l = new Label(sizeGroup, SWT.NONE); + l.setText("Width:"); + + mDisplayWidthText = new Text(sizeGroup, SWT.LEFT | SWT.SINGLE | SWT.BORDER); + mDisplayWidthText.setLayoutData(new GridData(GridData.FILL_HORIZONTAL)); + mDisplayWidthText.setText(Integer.toString( + store.getInt(EventLogPanel.PREFS_DISPLAY_WIDTH))); + mDisplayWidthText.addModifyListener(new ModifyListener() { + @Override + public void modifyText(ModifyEvent e) { + String text = mDisplayWidthText.getText().trim(); + try { + store.setValue(EventLogPanel.PREFS_DISPLAY_WIDTH, Integer.parseInt(text)); + setModified(); + } catch (NumberFormatException nfe) { + // do something? + } + } + }); + + l = new Label(sizeGroup, SWT.NONE); + l.setText("Height:"); + + mDisplayHeightText = new Text(sizeGroup, SWT.LEFT | SWT.SINGLE | SWT.BORDER); + mDisplayHeightText.setLayoutData(new GridData(GridData.FILL_HORIZONTAL)); + mDisplayHeightText.setText(Integer.toString( + store.getInt(EventLogPanel.PREFS_DISPLAY_HEIGHT))); + mDisplayHeightText.addModifyListener(new ModifyListener() { + @Override + public void modifyText(ModifyEvent e) { + String text = mDisplayHeightText.getText().trim(); + try { + store.setValue(EventLogPanel.PREFS_DISPLAY_HEIGHT, Integer.parseInt(text)); + setModified(); + } catch (NumberFormatException nfe) { + // do something? + } + } + }); + } + + private void createRightPanel(Composite rightPanel) { + rightPanel.setLayout(new GridLayout(1, true)); + rightPanel.setLayoutData(new GridData(GridData.FILL_BOTH)); + + mInfoGroup = new Group(rightPanel, SWT.NONE); + mInfoGroup.setLayoutData(new GridData(GridData.FILL_HORIZONTAL)); + mInfoGroup.setLayout(new GridLayout(2, false)); + + Label nameLabel = new Label(mInfoGroup, SWT.LEFT); + nameLabel.setText("Name:"); + + mDisplayNameText = new Text(mInfoGroup, SWT.BORDER | SWT.LEFT | SWT.SINGLE); + mDisplayNameText.setLayoutData(new GridData(GridData.FILL_HORIZONTAL)); + mDisplayNameText.addModifyListener(new ModifyListener() { + @Override + public void modifyText(ModifyEvent e) { + if (mProcessTextChanges) { + EventDisplay eventDisplay = getCurrentEventDisplay(); + if (eventDisplay != null) { + eventDisplay.setName(mDisplayNameText.getText()); + int index = mEventDisplayList.getSelectionIndex(); + mEventDisplayList.remove(index); + mEventDisplayList.add(eventDisplay.getName(), index); + mEventDisplayList.select(index); + handleEventDisplaySelection(); + setModified(); + } + } + } + }); + + Label displayLabel = new Label(mInfoGroup, SWT.LEFT); + displayLabel.setText("Type:"); + + mDisplayTypeCombo = new Combo(mInfoGroup, SWT.READ_ONLY | SWT.DROP_DOWN); + mDisplayTypeCombo.setLayoutData(new GridData(GridData.FILL_HORIZONTAL)); + // add the combo values. This must match the values EventDisplay.DISPLAY_TYPE_* + mDisplayTypeCombo.add("Log All"); + mDisplayTypeCombo.add("Filtered Log"); + mDisplayTypeCombo.add("Graph"); + mDisplayTypeCombo.add("Sync"); + mDisplayTypeCombo.add("Sync Histogram"); + mDisplayTypeCombo.add("Sync Performance"); + mDisplayTypeCombo.addSelectionListener(new SelectionAdapter() { + @Override + public void widgetSelected(SelectionEvent e) { + EventDisplay eventDisplay = getCurrentEventDisplay(); + if (eventDisplay != null && eventDisplay.getDisplayType() != mDisplayTypeCombo.getSelectionIndex()) { + /* Replace the EventDisplay object with a different subclass */ + setModified(); + String name = eventDisplay.getName(); + EventDisplay newEventDisplay = EventDisplay.eventDisplayFactory(mDisplayTypeCombo.getSelectionIndex(), name); + setCurrentEventDisplay(newEventDisplay); + fillUiWith(newEventDisplay); + } + } + }); + + mChartOptions = new Group(mInfoGroup, SWT.NONE); + mChartOptions.setText("Chart Options"); + GridData gd; + mChartOptions.setLayoutData(gd = new GridData(GridData.FILL_HORIZONTAL)); + gd.horizontalSpan = 2; + mChartOptions.setLayout(new GridLayout(2, false)); + + Label l = new Label(mChartOptions, SWT.NONE); + l.setText("Time Limit (seconds):"); + + mTimeLimitText = new Text(mChartOptions, SWT.BORDER); + mTimeLimitText.setLayoutData(new GridData(GridData.FILL_HORIZONTAL)); + mTimeLimitText.addModifyListener(new ModifyListener() { + @Override + public void modifyText(ModifyEvent arg0) { + String text = mTimeLimitText.getText().trim(); + EventDisplay eventDisplay = getCurrentEventDisplay(); + if (eventDisplay != null) { + try { + if (text.length() == 0) { + eventDisplay.resetChartTimeLimit(); + } else { + eventDisplay.setChartTimeLimit(Long.parseLong(text)); + } + } catch (NumberFormatException nfe) { + eventDisplay.resetChartTimeLimit(); + } finally { + setModified(); + } + } + } + }); + + mHistOptions = new Group(mInfoGroup, SWT.NONE); + mHistOptions.setText("Histogram Options"); + GridData gdh; + mHistOptions.setLayoutData(gdh = new GridData(GridData.FILL_HORIZONTAL)); + gdh.horizontalSpan = 2; + mHistOptions.setLayout(new GridLayout(2, false)); + + Label lh = new Label(mHistOptions, SWT.NONE); + lh.setText("Histogram width (hours):"); + + mHistWidthText = new Text(mHistOptions, SWT.BORDER); + mHistWidthText.setLayoutData(new GridData(GridData.FILL_HORIZONTAL)); + mHistWidthText.addModifyListener(new ModifyListener() { + @Override + public void modifyText(ModifyEvent arg0) { + String text = mHistWidthText.getText().trim(); + EventDisplay eventDisplay = getCurrentEventDisplay(); + if (eventDisplay != null) { + try { + if (text.length() == 0) { + eventDisplay.resetHistWidth(); + } else { + eventDisplay.setHistWidth(Long.parseLong(text)); + } + } catch (NumberFormatException nfe) { + eventDisplay.resetHistWidth(); + } finally { + setModified(); + } + } + } + }); + + mPidFilterCheckBox = new Button(mInfoGroup, SWT.CHECK); + mPidFilterCheckBox.setText("Enable filtering by pid"); + mPidFilterCheckBox.setLayoutData(gd = new GridData(GridData.FILL_HORIZONTAL)); + gd.horizontalSpan = 2; + mPidFilterCheckBox.addSelectionListener(new SelectionAdapter() { + @Override + public void widgetSelected(SelectionEvent e) { + EventDisplay eventDisplay = getCurrentEventDisplay(); + if (eventDisplay != null) { + eventDisplay.setPidFiltering(mPidFilterCheckBox.getSelection()); + mPidText.setEnabled(mPidFilterCheckBox.getSelection()); + setModified(); + } + } + }); + + Label pidLabel = new Label(mInfoGroup, SWT.NONE); + pidLabel.setText("Pid Filter:"); + pidLabel.setToolTipText("Enter all pids, separated by commas"); + + mPidText = new Text(mInfoGroup, SWT.BORDER); + mPidText.setLayoutData(new GridData(GridData.FILL_HORIZONTAL)); + mPidText.addModifyListener(new ModifyListener() { + @Override + public void modifyText(ModifyEvent e) { + if (mProcessTextChanges) { + EventDisplay eventDisplay = getCurrentEventDisplay(); + if (eventDisplay != null && eventDisplay.getPidFiltering()) { + String pidText = mPidText.getText().trim(); + String[] pids = pidText.split("\\s*,\\s*"); //$NON-NLS-1$ + + ArrayList list = new ArrayList(); + for (String pid : pids) { + try { + list.add(Integer.valueOf(pid)); + } catch (NumberFormatException nfe) { + // just ignore non valid pid + } + } + + eventDisplay.setPidFilterList(list); + setModified(); + } + } + } + }); + + /* ------------------ + * EVENT VALUE/OCCURRENCE SELECTION + * ------------------ */ + mValueSelection = createEventSelection(rightPanel, ValueDisplayDescriptor.class, + "Event Value Display"); + mOccurrenceSelection = createEventSelection(rightPanel, OccurrenceDisplayDescriptor.class, + "Event Occurrence Display"); + } + + private SelectionWidgets createEventSelection(Composite rightPanel, + final Class descriptorClass, + String groupMessage) { + + Group eventSelectionPanel = new Group(rightPanel, SWT.NONE); + eventSelectionPanel.setLayoutData(new GridData(GridData.FILL_BOTH)); + GridLayout gl; + eventSelectionPanel.setLayout(gl = new GridLayout(2, false)); + gl.marginHeight = gl.marginWidth = 0; + eventSelectionPanel.setText(groupMessage); + + final SelectionWidgets widgets = new SelectionWidgets(); + + widgets.mList = new List(eventSelectionPanel, SWT.BORDER | SWT.SINGLE | SWT.V_SCROLL); + widgets.mList.setLayoutData(new GridData(GridData.FILL_BOTH)); + widgets.mList.addSelectionListener(new SelectionAdapter() { + @Override + public void widgetSelected(SelectionEvent e) { + int index = widgets.mList.getSelectionIndex(); + if (index != -1) { + widgets.mDeleteButton.setEnabled(true); + widgets.mEditButton.setEnabled(true); + } else { + widgets.mDeleteButton.setEnabled(false); + widgets.mEditButton.setEnabled(false); + } + } + }); + + Composite rightControls = new Composite(eventSelectionPanel, SWT.NONE); + rightControls.setLayoutData(new GridData(GridData.FILL_VERTICAL)); + rightControls.setLayout(gl = new GridLayout(1, false)); + gl.marginHeight = gl.marginWidth = 0; + gl.verticalSpacing = 0; + gl.horizontalSpacing = 0; + + widgets.mNewButton = new Button(rightControls, SWT.PUSH | SWT.FLAT); + widgets.mNewButton.setText("New..."); + widgets.mNewButton.setLayoutData(new GridData(GridData.FILL_HORIZONTAL)); + widgets.mNewButton.setEnabled(false); + widgets.mNewButton.addSelectionListener(new SelectionAdapter() { + @Override + public void widgetSelected(SelectionEvent e) { + // current event + try { + EventDisplay eventDisplay = getCurrentEventDisplay(); + if (eventDisplay != null) { + EventValueSelector dialog = new EventValueSelector(mShell); + if (dialog.open(descriptorClass, mLogParser)) { + eventDisplay.addDescriptor(dialog.getDescriptor()); + fillUiWith(eventDisplay); + setModified(); + } + } + } catch (Exception e1) { + e1.printStackTrace(); + } + } + }); + + widgets.mEditButton = new Button(rightControls, SWT.PUSH | SWT.FLAT); + widgets.mEditButton.setText("Edit..."); + widgets.mEditButton.setLayoutData(new GridData(GridData.FILL_HORIZONTAL)); + widgets.mEditButton.setEnabled(false); + widgets.mEditButton.addSelectionListener(new SelectionAdapter() { + @Override + public void widgetSelected(SelectionEvent e) { + // current event + EventDisplay eventDisplay = getCurrentEventDisplay(); + if (eventDisplay != null) { + // get the current descriptor index + int index = widgets.mList.getSelectionIndex(); + if (index != -1) { + // get the descriptor itself + OccurrenceDisplayDescriptor descriptor = eventDisplay.getDescriptor( + descriptorClass, index); + + // open the edit dialog. + EventValueSelector dialog = new EventValueSelector(mShell); + if (dialog.open(descriptor, mLogParser)) { + descriptor.replaceWith(dialog.getDescriptor()); + eventDisplay.updateValueDescriptorCheck(); + fillUiWith(eventDisplay); + + // reselect the item since fillUiWith remove the selection. + widgets.mList.select(index); + widgets.mList.notifyListeners(SWT.Selection, null); + + setModified(); + } + } + } + } + }); + + widgets.mDeleteButton = new Button(rightControls, SWT.PUSH | SWT.FLAT); + widgets.mDeleteButton.setLayoutData(new GridData(GridData.FILL_HORIZONTAL)); + widgets.mDeleteButton.setText("Delete"); + widgets.mDeleteButton.setEnabled(false); + widgets.mDeleteButton.addSelectionListener(new SelectionAdapter() { + @Override + public void widgetSelected(SelectionEvent e) { + // current event + EventDisplay eventDisplay = getCurrentEventDisplay(); + if (eventDisplay != null) { + // get the current descriptor index + int index = widgets.mList.getSelectionIndex(); + if (index != -1) { + eventDisplay.removeDescriptor(descriptorClass, index); + fillUiWith(eventDisplay); + setModified(); + } + } + } + }); + + return widgets; + } + + + private void duplicateEventDisplay(ArrayList displayList) { + for (EventDisplay eventDisplay : displayList) { + mDisplayList.add(EventDisplay.clone(eventDisplay)); + } + } + + private void buildPidList(ArrayList eventList) { + mPidList = new ArrayList(); + for (EventContainer event : eventList) { + if (mPidList.indexOf(event.pid) == -1) { + mPidList.add(event.pid); + } + } + } + + private void setModified() { + mEditStatus = true; + } + + + private void enable(boolean status) { + mEventDisplayDeleteButton.setEnabled(status); + + // enable up/down + int selection = mEventDisplayList.getSelectionIndex(); + int count = mEventDisplayList.getItemCount(); + mEventDisplayUpButton.setEnabled(status && selection > 0); + mEventDisplayDownButton.setEnabled(status && selection != -1 && selection < count - 1); + + mDisplayNameText.setEnabled(status); + mDisplayTypeCombo.setEnabled(status); + mPidFilterCheckBox.setEnabled(status); + + mValueSelection.setEnabled(status); + mOccurrenceSelection.setEnabled(status); + mValueSelection.mNewButton.setEnabled(status); + mOccurrenceSelection.mNewButton.setEnabled(status); + if (status == false) { + mPidText.setEnabled(false); + } + } + + private void fillEventDisplayList() { + for (EventDisplay eventDisplay : mDisplayList) { + mEventDisplayList.add(eventDisplay.getName()); + } + } + + private void createNewEventDisplay() { + int count = mDisplayList.size(); + + String name = String.format("display %1$d", count + 1); + + EventDisplay eventDisplay = EventDisplay.eventDisplayFactory(0 /* type*/, name); + + mDisplayList.add(eventDisplay); + mEventDisplayList.add(name); + + mEventDisplayList.select(count); + handleEventDisplaySelection(); + mEventDisplayList.showSelection(); + + setModified(); + } + + private void deleteEventDisplay() { + int selection = mEventDisplayList.getSelectionIndex(); + if (selection != -1) { + mDisplayList.remove(selection); + mEventDisplayList.remove(selection); + if (mDisplayList.size() < selection) { + selection--; + } + mEventDisplayList.select(selection); + handleEventDisplaySelection(); + + setModified(); + } + } + + private EventDisplay getCurrentEventDisplay() { + int selection = mEventDisplayList.getSelectionIndex(); + if (selection != -1) { + return mDisplayList.get(selection); + } + + return null; + } + + private void setCurrentEventDisplay(EventDisplay eventDisplay) { + int selection = mEventDisplayList.getSelectionIndex(); + if (selection != -1) { + mDisplayList.set(selection, eventDisplay); + } + } + + private void handleEventDisplaySelection() { + EventDisplay eventDisplay = getCurrentEventDisplay(); + if (eventDisplay != null) { + // enable the UI + enable(true); + + // and fill it + fillUiWith(eventDisplay); + } else { + // disable the UI + enable(false); + + // and empty it. + emptyUi(); + } + } + + private void emptyUi() { + mDisplayNameText.setText(""); + mDisplayTypeCombo.clearSelection(); + mValueSelection.mList.removeAll(); + mOccurrenceSelection.mList.removeAll(); + } + + private void fillUiWith(EventDisplay eventDisplay) { + mProcessTextChanges = false; + + mDisplayNameText.setText(eventDisplay.getName()); + int displayMode = eventDisplay.getDisplayType(); + mDisplayTypeCombo.select(displayMode); + if (displayMode == EventDisplay.DISPLAY_TYPE_GRAPH) { + GridData gd = (GridData) mChartOptions.getLayoutData(); + gd.exclude = false; + mChartOptions.setVisible(!gd.exclude); + long limit = eventDisplay.getChartTimeLimit(); + if (limit != -1) { + mTimeLimitText.setText(Long.toString(limit)); + } else { + mTimeLimitText.setText(""); //$NON-NLS-1$ + } + } else { + GridData gd = (GridData) mChartOptions.getLayoutData(); + gd.exclude = true; + mChartOptions.setVisible(!gd.exclude); + mTimeLimitText.setText(""); //$NON-NLS-1$ + } + + if (displayMode == EventDisplay.DISPLAY_TYPE_SYNC_HIST) { + GridData gd = (GridData) mHistOptions.getLayoutData(); + gd.exclude = false; + mHistOptions.setVisible(!gd.exclude); + long limit = eventDisplay.getHistWidth(); + if (limit != -1) { + mHistWidthText.setText(Long.toString(limit)); + } else { + mHistWidthText.setText(""); //$NON-NLS-1$ + } + } else { + GridData gd = (GridData) mHistOptions.getLayoutData(); + gd.exclude = true; + mHistOptions.setVisible(!gd.exclude); + mHistWidthText.setText(""); //$NON-NLS-1$ + } + mInfoGroup.layout(true); + mShell.layout(true); + mShell.pack(); + + if (eventDisplay.getPidFiltering()) { + mPidFilterCheckBox.setSelection(true); + mPidText.setEnabled(true); + + // build the pid list. + ArrayList list = eventDisplay.getPidFilterList(); + if (list != null) { + StringBuilder sb = new StringBuilder(); + int count = list.size(); + for (int i = 0 ; i < count ; i++) { + sb.append(list.get(i)); + if (i < count - 1) { + sb.append(", ");//$NON-NLS-1$ + } + } + mPidText.setText(sb.toString()); + } else { + mPidText.setText(""); //$NON-NLS-1$ + } + } else { + mPidFilterCheckBox.setSelection(false); + mPidText.setEnabled(false); + mPidText.setText(""); //$NON-NLS-1$ + } + + mProcessTextChanges = true; + + mValueSelection.mList.removeAll(); + mOccurrenceSelection.mList.removeAll(); + + if (eventDisplay.getDisplayType() == EventDisplay.DISPLAY_TYPE_FILTERED_LOG || + eventDisplay.getDisplayType() == EventDisplay.DISPLAY_TYPE_GRAPH) { + mOccurrenceSelection.setEnabled(true); + mValueSelection.setEnabled(true); + + Iterator valueIterator = eventDisplay.getValueDescriptors(); + + while (valueIterator.hasNext()) { + ValueDisplayDescriptor descriptor = valueIterator.next(); + mValueSelection.mList.add(String.format("%1$s: %2$s [%3$s]%4$s", + mEventTagMap.get(descriptor.eventTag), descriptor.valueName, + getSeriesLabelDescription(descriptor), getFilterDescription(descriptor))); + } + + Iterator occurrenceIterator = + eventDisplay.getOccurrenceDescriptors(); + + while (occurrenceIterator.hasNext()) { + OccurrenceDisplayDescriptor descriptor = occurrenceIterator.next(); + + mOccurrenceSelection.mList.add(String.format("%1$s [%2$s]%3$s", + mEventTagMap.get(descriptor.eventTag), + getSeriesLabelDescription(descriptor), + getFilterDescription(descriptor))); + } + + mValueSelection.mList.notifyListeners(SWT.Selection, null); + mOccurrenceSelection.mList.notifyListeners(SWT.Selection, null); + } else { + mOccurrenceSelection.setEnabled(false); + mValueSelection.setEnabled(false); + } + + } + + /** + * Returns a String describing what is used as the series label + * @param descriptor the descriptor of the display. + */ + private String getSeriesLabelDescription(OccurrenceDisplayDescriptor descriptor) { + if (descriptor.seriesValueIndex != -1) { + if (descriptor.includePid) { + return String.format("%1$s + pid", + mEventDescriptionMap.get( + descriptor.eventTag)[descriptor.seriesValueIndex].getName()); + } else { + return mEventDescriptionMap.get(descriptor.eventTag)[descriptor.seriesValueIndex] + .getName(); + } + } + return "pid"; + } + + private String getFilterDescription(OccurrenceDisplayDescriptor descriptor) { + if (descriptor.filterValueIndex != -1) { + return String.format(" [%1$s %2$s %3$s]", + mEventDescriptionMap.get( + descriptor.eventTag)[descriptor.filterValueIndex].getName(), + descriptor.filterCompareMethod.testString(), + descriptor.filterValue != null ? + descriptor.filterValue.toString() : "?"); //$NON-NLS-1$ + } + return ""; //$NON-NLS-1$ + } + +} diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/log/event/EventLogImporter.java b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/log/event/EventLogImporter.java new file mode 100644 index 00000000..011bcf1d --- /dev/null +++ b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/log/event/EventLogImporter.java @@ -0,0 +1,95 @@ +/* + * Copyright (C) 2008 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.ddmuilib.log.event; + +import com.android.ddmlib.Log; + +import java.io.BufferedReader; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStreamReader; +import java.util.ArrayList; + +/** + * Imports a textual event log. Gets tags from build path. + */ +public class EventLogImporter { + + private String[] mTags; + private String[] mLog; + + public EventLogImporter(String filePath) throws FileNotFoundException { + String top = System.getenv("ANDROID_BUILD_TOP"); + if (top == null) { + throw new FileNotFoundException(); + } + final String tagFile = top + "/system/core/logcat/event-log-tags"; + BufferedReader tagReader = new BufferedReader( + new InputStreamReader(new FileInputStream(tagFile))); + BufferedReader eventReader = new BufferedReader( + new InputStreamReader(new FileInputStream(filePath))); + try { + readTags(tagReader); + readLog(eventReader); + } catch (IOException e) { + } finally { + if (tagReader != null) { + try { + tagReader.close(); + } catch (IOException ignore) { + } + } + if (eventReader != null) { + try { + eventReader.close(); + } catch (IOException ignore) { + } + } + } + } + + public String[] getTags() { + return mTags; + } + + public String[] getLog() { + return mLog; + } + + private void readTags(BufferedReader reader) throws IOException { + String line; + + ArrayList content = new ArrayList(); + while ((line = reader.readLine()) != null) { + content.add(line); + } + mTags = content.toArray(new String[content.size()]); + } + + private void readLog(BufferedReader reader) throws IOException { + String line; + + ArrayList content = new ArrayList(); + while ((line = reader.readLine()) != null) { + content.add(line); + } + + mLog = content.toArray(new String[content.size()]); + } + +} diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/log/event/EventLogPanel.java b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/log/event/EventLogPanel.java new file mode 100644 index 00000000..4faac3a9 --- /dev/null +++ b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/log/event/EventLogPanel.java @@ -0,0 +1,935 @@ +/* + * Copyright (C) 2008 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.ddmuilib.log.event; + +import com.android.ddmlib.Client; +import com.android.ddmlib.IDevice; +import com.android.ddmlib.Log; +import com.android.ddmlib.Log.LogLevel; +import com.android.ddmlib.log.EventContainer; +import com.android.ddmlib.log.EventLogParser; +import com.android.ddmlib.log.LogReceiver; +import com.android.ddmlib.log.LogReceiver.ILogListener; +import com.android.ddmlib.log.LogReceiver.LogEntry; +import com.android.ddmuilib.DdmUiPreferences; +import com.android.ddmuilib.TablePanel; +import com.android.ddmuilib.actions.ICommonAction; +import com.android.ddmuilib.annotation.UiThread; +import com.android.ddmuilib.annotation.WorkerThread; +import com.android.ddmuilib.log.event.EventDisplay.ILogColumnListener; + +import org.eclipse.jface.preference.IPreferenceStore; +import org.eclipse.swt.SWT; +import org.eclipse.swt.SWTException; +import org.eclipse.swt.custom.ScrolledComposite; +import org.eclipse.swt.events.ControlAdapter; +import org.eclipse.swt.events.ControlEvent; +import org.eclipse.swt.events.DisposeEvent; +import org.eclipse.swt.events.DisposeListener; +import org.eclipse.swt.graphics.Rectangle; +import org.eclipse.swt.layout.GridData; +import org.eclipse.swt.layout.RowData; +import org.eclipse.swt.layout.RowLayout; +import org.eclipse.swt.widgets.Composite; +import org.eclipse.swt.widgets.Control; +import org.eclipse.swt.widgets.Display; +import org.eclipse.swt.widgets.FileDialog; +import org.eclipse.swt.widgets.Table; +import org.eclipse.swt.widgets.TableColumn; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.IOException; +import java.text.NumberFormat; +import java.util.ArrayList; +import java.util.regex.Pattern; + +/** + * Event log viewer + */ +public class EventLogPanel extends TablePanel implements ILogListener, + ILogColumnListener { + + private final static String TAG_FILE_EXT = ".tag"; //$NON-NLS-1$ + + private final static String PREFS_EVENT_DISPLAY = "EventLogPanel.eventDisplay"; //$NON-NLS-1$ + private final static String EVENT_DISPLAY_STORAGE_SEPARATOR = "|"; //$NON-NLS-1$ + + static final String PREFS_DISPLAY_WIDTH = "EventLogPanel.width"; //$NON-NLS-1$ + static final String PREFS_DISPLAY_HEIGHT = "EventLogPanel.height"; //$NON-NLS-1$ + + private final static int DEFAULT_DISPLAY_WIDTH = 500; + private final static int DEFAULT_DISPLAY_HEIGHT = 400; + + private IDevice mCurrentLoggedDevice; + private String mCurrentLogFile; + private LogReceiver mCurrentLogReceiver; + private EventLogParser mCurrentEventLogParser; + + private Object mLock = new Object(); + + /** list of all the events. */ + private final ArrayList mEvents = new ArrayList(); + + /** list of all the new events, that have yet to be displayed by the ui */ + private final ArrayList mNewEvents = new ArrayList(); + /** indicates a pending ui thread display */ + private boolean mPendingDisplay = false; + + /** list of all the custom event displays */ + private final ArrayList mEventDisplays = new ArrayList(); + + private final NumberFormat mFormatter = NumberFormat.getInstance(); + private Composite mParent; + private ScrolledComposite mBottomParentPanel; + private Composite mBottomPanel; + private ICommonAction mOptionsAction; + private ICommonAction mClearAction; + private ICommonAction mSaveAction; + private ICommonAction mLoadAction; + private ICommonAction mImportAction; + + /** file containing the current log raw data. */ + private File mTempFile = null; + + public EventLogPanel() { + super(); + mFormatter.setGroupingUsed(true); + } + + /** + * Sets the external actions. + *

This method sets up the {@link ICommonAction} objects to execute the proper code + * when triggered by using {@link ICommonAction#setRunnable(Runnable)}. + *

It will also make sure they are enabled only when possible. + * @param optionsAction + * @param clearAction + * @param saveAction + * @param loadAction + * @param importAction + */ + public void setActions(ICommonAction optionsAction, ICommonAction clearAction, + ICommonAction saveAction, ICommonAction loadAction, ICommonAction importAction) { + mOptionsAction = optionsAction; + mOptionsAction.setRunnable(new Runnable() { + @Override + public void run() { + openOptionPanel(); + } + }); + + mClearAction = clearAction; + mClearAction.setRunnable(new Runnable() { + @Override + public void run() { + clearLog(); + } + }); + + mSaveAction = saveAction; + mSaveAction.setRunnable(new Runnable() { + @Override + public void run() { + try { + FileDialog fileDialog = new FileDialog(mParent.getShell(), SWT.SAVE); + + fileDialog.setText("Save Event Log"); + fileDialog.setFileName("event.log"); + + String fileName = fileDialog.open(); + if (fileName != null) { + saveLog(fileName); + } + } catch (IOException e1) { + } + } + }); + + mLoadAction = loadAction; + mLoadAction.setRunnable(new Runnable() { + @Override + public void run() { + FileDialog fileDialog = new FileDialog(mParent.getShell(), SWT.OPEN); + + fileDialog.setText("Load Event Log"); + + String fileName = fileDialog.open(); + if (fileName != null) { + loadLog(fileName); + } + } + }); + + mImportAction = importAction; + mImportAction.setRunnable(new Runnable() { + @Override + public void run() { + FileDialog fileDialog = new FileDialog(mParent.getShell(), SWT.OPEN); + + fileDialog.setText("Import Bug Report"); + + String fileName = fileDialog.open(); + if (fileName != null) { + importBugReport(fileName); + } + } + }); + + mOptionsAction.setEnabled(false); + mClearAction.setEnabled(false); + mSaveAction.setEnabled(false); + } + + /** + * Opens the option panel. + *

+ * This must be called from the UI thread + */ + @UiThread + public void openOptionPanel() { + try { + EventDisplayOptions dialog = new EventDisplayOptions(mParent.getShell()); + if (dialog.open(mCurrentEventLogParser, mEventDisplays, mEvents)) { + synchronized (mLock) { + // get the new EventDisplay list + mEventDisplays.clear(); + mEventDisplays.addAll(dialog.getEventDisplays()); + + // since the list of EventDisplay changed, we store it. + saveEventDisplays(); + + rebuildUi(); + } + } + } catch (SWTException e) { + Log.e("EventLog", e); //$NON-NLS-1$ + } + } + + /** + * Clears the log. + *

+ * This must be called from the UI thread + */ + public void clearLog() { + try { + synchronized (mLock) { + mEvents.clear(); + mNewEvents.clear(); + mPendingDisplay = false; + for (EventDisplay eventDisplay : mEventDisplays) { + eventDisplay.resetUI(); + } + } + } catch (SWTException e) { + Log.e("EventLog", e); //$NON-NLS-1$ + } + } + + /** + * Saves the content of the event log into a file. The log is saved in the same + * binary format than on the device. + * @param filePath + * @throws IOException + */ + public void saveLog(String filePath) throws IOException { + if (mCurrentLoggedDevice != null && mCurrentEventLogParser != null) { + File destFile = new File(filePath); + destFile.createNewFile(); + FileInputStream fis = new FileInputStream(mTempFile); + FileOutputStream fos = new FileOutputStream(destFile); + byte[] buffer = new byte[1024]; + + int count; + + while ((count = fis.read(buffer)) != -1) { + fos.write(buffer, 0, count); + } + + fos.close(); + fis.close(); + + // now we save the tag file + filePath = filePath + TAG_FILE_EXT; + mCurrentEventLogParser.saveTags(filePath); + } + } + + /** + * Loads a binary event log (if has associated .tag file) or + * otherwise loads a textual event log. + * @param filePath Event log path (and base of potential tag file) + */ + public void loadLog(String filePath) { + if ((new File(filePath + TAG_FILE_EXT)).exists()) { + startEventLogFromFiles(filePath); + } else { + try { + EventLogImporter importer = new EventLogImporter(filePath); + String[] tags = importer.getTags(); + String[] log = importer.getLog(); + startEventLogFromContent(tags, log); + } catch (FileNotFoundException e) { + // If this fails, display the error message from startEventLogFromFiles, + // and pretend we never tried EventLogImporter + Log.logAndDisplay(Log.LogLevel.ERROR, "EventLog", + String.format("Failure to read %1$s", filePath + TAG_FILE_EXT)); + } + + } + } + + public void importBugReport(String filePath) { + try { + BugReportImporter importer = new BugReportImporter(filePath); + + String[] tags = importer.getTags(); + String[] log = importer.getLog(); + + startEventLogFromContent(tags, log); + + } catch (FileNotFoundException e) { + Log.logAndDisplay(LogLevel.ERROR, "Import", + "Unable to import bug report: " + e.getMessage()); + } + } + + /* (non-Javadoc) + * @see com.android.ddmuilib.SelectionDependentPanel#clientSelected() + */ + @Override + public void clientSelected() { + // pass + } + + /* (non-Javadoc) + * @see com.android.ddmuilib.SelectionDependentPanel#deviceSelected() + */ + @Override + public void deviceSelected() { + startEventLog(getCurrentDevice()); + } + + /* + * (non-Javadoc) + * @see com.android.ddmlib.AndroidDebugBridge.IClientChangeListener#clientChanged(com.android.ddmlib.Client, int) + */ + @Override + public void clientChanged(Client client, int changeMask) { + // pass + } + + /* (non-Javadoc) + * @see com.android.ddmuilib.Panel#createControl(org.eclipse.swt.widgets.Composite) + */ + @Override + protected Control createControl(Composite parent) { + mParent = parent; + mParent.addDisposeListener(new DisposeListener() { + @Override + public void widgetDisposed(DisposeEvent e) { + synchronized (mLock) { + if (mCurrentLogReceiver != null) { + mCurrentLogReceiver.cancel(); + mCurrentLogReceiver = null; + mCurrentEventLogParser = null; + mCurrentLoggedDevice = null; + mEventDisplays.clear(); + mEvents.clear(); + } + } + } + }); + + final IPreferenceStore store = DdmUiPreferences.getStore(); + + // init some store stuff + store.setDefault(PREFS_DISPLAY_WIDTH, DEFAULT_DISPLAY_WIDTH); + store.setDefault(PREFS_DISPLAY_HEIGHT, DEFAULT_DISPLAY_HEIGHT); + + mBottomParentPanel = new ScrolledComposite(parent, SWT.V_SCROLL); + mBottomParentPanel.setLayoutData(new GridData(GridData.FILL_BOTH)); + mBottomParentPanel.setExpandHorizontal(true); + mBottomParentPanel.setExpandVertical(true); + + mBottomParentPanel.addControlListener(new ControlAdapter() { + @Override + public void controlResized(ControlEvent e) { + if (mBottomPanel != null) { + Rectangle r = mBottomParentPanel.getClientArea(); + mBottomParentPanel.setMinSize(mBottomPanel.computeSize(r.width, + SWT.DEFAULT)); + } + } + }); + + prepareDisplayUi(); + + // load the EventDisplay from storage. + loadEventDisplays(); + + // create the ui + createDisplayUi(); + + return mBottomParentPanel; + } + + /* (non-Javadoc) + * @see com.android.ddmuilib.Panel#postCreation() + */ + @Override + protected void postCreation() { + // pass + } + + /* (non-Javadoc) + * @see com.android.ddmuilib.Panel#setFocus() + */ + @Override + public void setFocus() { + mBottomParentPanel.setFocus(); + } + + /** + * Starts a new logcat and set mCurrentLogCat as the current receiver. + * @param device the device to connect logcat to. + */ + private void startEventLog(final IDevice device) { + if (device == mCurrentLoggedDevice) { + return; + } + + // if we have a logcat already running + if (mCurrentLogReceiver != null) { + stopEventLog(false); + } + mCurrentLoggedDevice = null; + mCurrentLogFile = null; + + if (device != null) { + // create a new output receiver + mCurrentLogReceiver = new LogReceiver(this); + + // start the logcat in a different thread + new Thread("EventLog") { //$NON-NLS-1$ + @Override + public void run() { + while (device.isOnline() == false && + mCurrentLogReceiver != null && + mCurrentLogReceiver.isCancelled() == false) { + try { + sleep(2000); + } catch (InterruptedException e) { + return; + } + } + + if (mCurrentLogReceiver == null || mCurrentLogReceiver.isCancelled()) { + // logcat was stopped/cancelled before the device became ready. + return; + } + + try { + mCurrentLoggedDevice = device; + synchronized (mLock) { + mCurrentEventLogParser = new EventLogParser(); + mCurrentEventLogParser.init(device); + } + + // update the event display with the new parser. + updateEventDisplays(); + + // prepare the temp file that will contain the raw data + mTempFile = File.createTempFile("android-event-", ".log"); + + device.runEventLogService(mCurrentLogReceiver); + } catch (Exception e) { + Log.e("EventLog", e); + } finally { + } + } + }.start(); + } + } + + private void startEventLogFromFiles(final String fileName) { + // if we have a logcat already running + if (mCurrentLogReceiver != null) { + stopEventLog(false); + } + mCurrentLoggedDevice = null; + mCurrentLogFile = null; + + // create a new output receiver + mCurrentLogReceiver = new LogReceiver(this); + + mSaveAction.setEnabled(false); + + // start the logcat in a different thread + new Thread("EventLog") { //$NON-NLS-1$ + @Override + public void run() { + try { + mCurrentLogFile = fileName; + synchronized (mLock) { + mCurrentEventLogParser = new EventLogParser(); + if (mCurrentEventLogParser.init(fileName + TAG_FILE_EXT) == false) { + mCurrentEventLogParser = null; + Log.logAndDisplay(LogLevel.ERROR, "EventLog", + String.format("Failure to read %1$s", fileName + TAG_FILE_EXT)); + return; + } + } + + // update the event display with the new parser. + updateEventDisplays(); + + runLocalEventLogService(fileName, mCurrentLogReceiver); + } catch (Exception e) { + Log.e("EventLog", e); + } finally { + } + } + }.start(); + } + + private void startEventLogFromContent(final String[] tags, final String[] log) { + // if we have a logcat already running + if (mCurrentLogReceiver != null) { + stopEventLog(false); + } + mCurrentLoggedDevice = null; + mCurrentLogFile = null; + + // create a new output receiver + mCurrentLogReceiver = new LogReceiver(this); + + mSaveAction.setEnabled(false); + + // start the logcat in a different thread + new Thread("EventLog") { //$NON-NLS-1$ + @Override + public void run() { + try { + synchronized (mLock) { + mCurrentEventLogParser = new EventLogParser(); + if (mCurrentEventLogParser.init(tags) == false) { + mCurrentEventLogParser = null; + return; + } + } + + // update the event display with the new parser. + updateEventDisplays(); + + runLocalEventLogService(log, mCurrentLogReceiver); + } catch (Exception e) { + Log.e("EventLog", e); + } finally { + } + } + }.start(); + } + + + public void stopEventLog(boolean inUiThread) { + if (mCurrentLogReceiver != null) { + mCurrentLogReceiver.cancel(); + + // when the thread finishes, no one will reference that object + // and it'll be destroyed + synchronized (mLock) { + mCurrentLogReceiver = null; + mCurrentEventLogParser = null; + + mCurrentLoggedDevice = null; + mEvents.clear(); + mNewEvents.clear(); + mPendingDisplay = false; + } + + resetUI(inUiThread); + } + + if (mTempFile != null) { + mTempFile.delete(); + mTempFile = null; + } + } + + private void resetUI(boolean inUiThread) { + mEvents.clear(); + + // the ui is static we just empty it. + if (inUiThread) { + resetUiFromUiThread(); + } else { + try { + Display d = mBottomParentPanel.getDisplay(); + + // run sync as we need to update right now. + d.syncExec(new Runnable() { + @Override + public void run() { + if (mBottomParentPanel.isDisposed() == false) { + resetUiFromUiThread(); + } + } + }); + } catch (SWTException e) { + // display is disposed, we're quitting. Do nothing. + } + } + } + + private void resetUiFromUiThread() { + synchronized (mLock) { + for (EventDisplay eventDisplay : mEventDisplays) { + eventDisplay.resetUI(); + } + } + mOptionsAction.setEnabled(false); + mClearAction.setEnabled(false); + mSaveAction.setEnabled(false); + } + + private void prepareDisplayUi() { + mBottomPanel = new Composite(mBottomParentPanel, SWT.NONE); + mBottomParentPanel.setContent(mBottomPanel); + } + + private void createDisplayUi() { + RowLayout rowLayout = new RowLayout(); + rowLayout.wrap = true; + rowLayout.pack = false; + rowLayout.justify = true; + rowLayout.fill = true; + rowLayout.type = SWT.HORIZONTAL; + mBottomPanel.setLayout(rowLayout); + + IPreferenceStore store = DdmUiPreferences.getStore(); + int displayWidth = store.getInt(PREFS_DISPLAY_WIDTH); + int displayHeight = store.getInt(PREFS_DISPLAY_HEIGHT); + + for (EventDisplay eventDisplay : mEventDisplays) { + Control c = eventDisplay.createComposite(mBottomPanel, mCurrentEventLogParser, this); + if (c != null) { + RowData rd = new RowData(); + rd.height = displayHeight; + rd.width = displayWidth; + c.setLayoutData(rd); + } + + Table table = eventDisplay.getTable(); + if (table != null) { + addTableToFocusListener(table); + } + } + + mBottomPanel.layout(); + mBottomParentPanel.setMinSize(mBottomPanel.computeSize(SWT.DEFAULT, SWT.DEFAULT)); + mBottomParentPanel.layout(); + } + + /** + * Rebuild the display ui. + */ + @UiThread + private void rebuildUi() { + synchronized (mLock) { + // we need to rebuild the ui. First we get rid of it. + mBottomPanel.dispose(); + mBottomPanel = null; + + prepareDisplayUi(); + createDisplayUi(); + + // and fill it + + boolean start_event = false; + synchronized (mNewEvents) { + mNewEvents.addAll(0, mEvents); + + if (mPendingDisplay == false) { + mPendingDisplay = true; + start_event = true; + } + } + + if (start_event) { + scheduleUIEventHandler(); + } + + Rectangle r = mBottomParentPanel.getClientArea(); + mBottomParentPanel.setMinSize(mBottomPanel.computeSize(r.width, + SWT.DEFAULT)); + } + } + + + /** + * Processes a new {@link LogEntry} by parsing it with {@link EventLogParser} and displaying it. + * @param entry The new log entry + * @see LogReceiver.ILogListener#newEntry(LogEntry) + */ + @Override + @WorkerThread + public void newEntry(LogEntry entry) { + synchronized (mLock) { + if (mCurrentEventLogParser != null) { + EventContainer event = mCurrentEventLogParser.parse(entry); + if (event != null) { + handleNewEvent(event); + } + } + } + } + + @WorkerThread + private void handleNewEvent(EventContainer event) { + // add the event to the generic list + mEvents.add(event); + + // add to the list of events that needs to be displayed, and trigger a + // new display if needed. + boolean start_event = false; + synchronized (mNewEvents) { + mNewEvents.add(event); + + if (mPendingDisplay == false) { + mPendingDisplay = true; + start_event = true; + } + } + + if (start_event == false) { + // we're done + return; + } + + scheduleUIEventHandler(); + } + + /** + * Schedules the UI thread to execute a {@link Runnable} calling {@link #displayNewEvents()}. + */ + private void scheduleUIEventHandler() { + try { + Display d = mBottomParentPanel.getDisplay(); + d.asyncExec(new Runnable() { + @Override + public void run() { + if (mBottomParentPanel.isDisposed() == false) { + if (mCurrentEventLogParser != null) { + displayNewEvents(); + } + } + } + }); + } catch (SWTException e) { + // if the ui is disposed, do nothing + } + } + + /** + * Processes raw data coming from the log service. + * @see LogReceiver.ILogListener#newData(byte[], int, int) + */ + @Override + public void newData(byte[] data, int offset, int length) { + if (mTempFile != null) { + try { + FileOutputStream fos = new FileOutputStream(mTempFile, true /* append */); + fos.write(data, offset, length); + fos.close(); + } catch (FileNotFoundException e) { + } catch (IOException e) { + } + } + } + + @UiThread + private void displayNewEvents() { + // never display more than 1,000 events in this loop. We can't do too much in the UI thread. + int count = 0; + + // prepare the displays + for (EventDisplay eventDisplay : mEventDisplays) { + eventDisplay.startMultiEventDisplay(); + } + + // display the new events + EventContainer event = null; + boolean need_to_reloop = false; + do { + // get the next event to display. + synchronized (mNewEvents) { + if (mNewEvents.size() > 0) { + if (count > 200) { + // there are still events to be displayed, but we don't want to hog the + // UI thread for too long, so we stop this runnable, but launch a new + // one to keep going. + need_to_reloop = true; + event = null; + } else { + event = mNewEvents.remove(0); + count++; + } + } else { + // we're done. + event = null; + mPendingDisplay = false; + } + } + + if (event != null) { + // notify the event display + for (EventDisplay eventDisplay : mEventDisplays) { + eventDisplay.newEvent(event, mCurrentEventLogParser); + } + } + } while (event != null); + + // we're done displaying events. + for (EventDisplay eventDisplay : mEventDisplays) { + eventDisplay.endMultiEventDisplay(); + } + + // if needed, ask the UI thread to re-run this method. + if (need_to_reloop) { + scheduleUIEventHandler(); + } + } + + /** + * Loads the {@link EventDisplay}s from the preference store. + */ + private void loadEventDisplays() { + IPreferenceStore store = DdmUiPreferences.getStore(); + String storage = store.getString(PREFS_EVENT_DISPLAY); + + if (storage.length() > 0) { + String[] values = storage.split(Pattern.quote(EVENT_DISPLAY_STORAGE_SEPARATOR)); + + for (String value : values) { + EventDisplay eventDisplay = EventDisplay.load(value); + if (eventDisplay != null) { + mEventDisplays.add(eventDisplay); + } + } + } + } + + /** + * Saves the {@link EventDisplay}s into the {@link DdmUiPreferences} store. + */ + private void saveEventDisplays() { + IPreferenceStore store = DdmUiPreferences.getStore(); + + boolean first = true; + StringBuilder sb = new StringBuilder(); + + for (EventDisplay eventDisplay : mEventDisplays) { + String storage = eventDisplay.getStorageString(); + if (storage != null) { + if (first == false) { + sb.append(EVENT_DISPLAY_STORAGE_SEPARATOR); + } else { + first = false; + } + + sb.append(storage); + } + } + + store.setValue(PREFS_EVENT_DISPLAY, sb.toString()); + } + + /** + * Updates the {@link EventDisplay} with the new {@link EventLogParser}. + *

+ * This will run asynchronously in the UI thread. + */ + @WorkerThread + private void updateEventDisplays() { + try { + Display d = mBottomParentPanel.getDisplay(); + + d.asyncExec(new Runnable() { + @Override + public void run() { + if (mBottomParentPanel.isDisposed() == false) { + for (EventDisplay eventDisplay : mEventDisplays) { + eventDisplay.setNewLogParser(mCurrentEventLogParser); + } + + mOptionsAction.setEnabled(true); + mClearAction.setEnabled(true); + if (mCurrentLogFile == null) { + mSaveAction.setEnabled(true); + } else { + mSaveAction.setEnabled(false); + } + } + } + }); + } catch (SWTException e) { + // display is disposed: do nothing. + } + } + + @Override + @UiThread + public void columnResized(int index, TableColumn sourceColumn) { + for (EventDisplay eventDisplay : mEventDisplays) { + eventDisplay.resizeColumn(index, sourceColumn); + } + } + + /** + * Runs an event log service out of a local file. + * @param fileName the full file name of the local file containing the event log. + * @param logReceiver the receiver that will handle the log + * @throws IOException + */ + @WorkerThread + private void runLocalEventLogService(String fileName, LogReceiver logReceiver) + throws IOException { + byte[] buffer = new byte[256]; + + FileInputStream fis = new FileInputStream(fileName); + + int count; + while ((count = fis.read(buffer)) != -1) { + logReceiver.parseNewData(buffer, 0, count); + } + } + + @WorkerThread + private void runLocalEventLogService(String[] log, LogReceiver currentLogReceiver) { + synchronized (mLock) { + for (String line : log) { + EventContainer event = mCurrentEventLogParser.parse(line); + if (event != null) { + handleNewEvent(event); + } + } + } + } +} diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/log/event/EventValueSelector.java b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/log/event/EventValueSelector.java new file mode 100644 index 00000000..e7c5196f --- /dev/null +++ b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/log/event/EventValueSelector.java @@ -0,0 +1,630 @@ +/* + * Copyright (C) 2008 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.ddmuilib.log.event; + +import com.android.ddmlib.log.EventContainer.CompareMethod; +import com.android.ddmlib.log.EventContainer.EventValueType; +import com.android.ddmlib.log.EventLogParser; +import com.android.ddmlib.log.EventValueDescription; +import com.android.ddmuilib.log.event.EventDisplay.OccurrenceDisplayDescriptor; +import com.android.ddmuilib.log.event.EventDisplay.ValueDisplayDescriptor; + +import org.eclipse.swt.SWT; +import org.eclipse.swt.events.ModifyEvent; +import org.eclipse.swt.events.ModifyListener; +import org.eclipse.swt.events.SelectionAdapter; +import org.eclipse.swt.events.SelectionEvent; +import org.eclipse.swt.graphics.Rectangle; +import org.eclipse.swt.layout.GridData; +import org.eclipse.swt.layout.GridLayout; +import org.eclipse.swt.widgets.Button; +import org.eclipse.swt.widgets.Combo; +import org.eclipse.swt.widgets.Composite; +import org.eclipse.swt.widgets.Dialog; +import org.eclipse.swt.widgets.Display; +import org.eclipse.swt.widgets.Event; +import org.eclipse.swt.widgets.Label; +import org.eclipse.swt.widgets.Listener; +import org.eclipse.swt.widgets.Shell; +import org.eclipse.swt.widgets.Text; + +import java.util.ArrayList; +import java.util.Map; +import java.util.Set; + +final class EventValueSelector extends Dialog { + private static final int DLG_WIDTH = 400; + private static final int DLG_HEIGHT = 300; + + private Shell mParent; + private Shell mShell; + private boolean mEditStatus; + private Combo mEventCombo; + private Combo mValueCombo; + private Combo mSeriesCombo; + private Button mDisplayPidCheckBox; + private Combo mFilterCombo; + private Combo mFilterMethodCombo; + private Text mFilterValue; + private Button mOkButton; + + private EventLogParser mLogParser; + private OccurrenceDisplayDescriptor mDescriptor; + + /** list of event integer in the order of the combo. */ + private Integer[] mEventTags; + + /** list of indices in the {@link EventValueDescription} array of the current event + * that are of type string. This lets us get back the {@link EventValueDescription} from the + * index in the Series {@link Combo}. + */ + private final ArrayList mSeriesIndices = new ArrayList(); + + public EventValueSelector(Shell parent) { + super(parent, SWT.DIALOG_TRIM | SWT.BORDER | SWT.APPLICATION_MODAL); + } + + /** + * Opens the display option dialog to edit a new descriptor. + * @param decriptorClass the class of the object to instantiate. Must extend + * {@link OccurrenceDisplayDescriptor} + * @param logParser + * @return true if the object is to be created, false if the creation was canceled. + */ + boolean open(Class descriptorClass, + EventLogParser logParser) { + try { + OccurrenceDisplayDescriptor descriptor = descriptorClass.newInstance(); + setModified(); + return open(descriptor, logParser); + } catch (InstantiationException e) { + return false; + } catch (IllegalAccessException e) { + return false; + } + } + + /** + * Opens the display option dialog, to edit a {@link OccurrenceDisplayDescriptor} object or + * a {@link ValueDisplayDescriptor} object. + * @param descriptor The descriptor to edit. + * @return true if the object was modified. + */ + boolean open(OccurrenceDisplayDescriptor descriptor, EventLogParser logParser) { + // make a copy of the descriptor as we'll use a working copy. + if (descriptor instanceof ValueDisplayDescriptor) { + mDescriptor = new ValueDisplayDescriptor((ValueDisplayDescriptor)descriptor); + } else if (descriptor instanceof OccurrenceDisplayDescriptor) { + mDescriptor = new OccurrenceDisplayDescriptor(descriptor); + } else { + return false; + } + + mLogParser = logParser; + + createUI(); + + if (mParent == null || mShell == null) { + return false; + } + + loadValueDescriptor(); + + checkValidity(); + + // Set the dialog size. + try { + mShell.setMinimumSize(DLG_WIDTH, DLG_HEIGHT); + Rectangle r = mParent.getBounds(); + // get the center new top left. + int cx = r.x + r.width/2; + int x = cx - DLG_WIDTH / 2; + int cy = r.y + r.height/2; + int y = cy - DLG_HEIGHT / 2; + mShell.setBounds(x, y, DLG_WIDTH, DLG_HEIGHT); + } catch (Exception e) { + e.printStackTrace(); + } + + mShell.layout(); + + // actually open the dialog + mShell.open(); + + // event loop until the dialog is closed. + Display display = mParent.getDisplay(); + while (!mShell.isDisposed()) { + if (!display.readAndDispatch()) + display.sleep(); + } + + return mEditStatus; + } + + OccurrenceDisplayDescriptor getDescriptor() { + return mDescriptor; + } + + private void createUI() { + GridData gd; + + mParent = getParent(); + mShell = new Shell(mParent, getStyle()); + mShell.setText("Event Display Configuration"); + + mShell.setLayout(new GridLayout(2, false)); + + Label l = new Label(mShell, SWT.NONE); + l.setText("Event:"); + + mEventCombo = new Combo(mShell, SWT.DROP_DOWN | SWT.READ_ONLY); + mEventCombo.setLayoutData(new GridData(GridData.FILL_HORIZONTAL)); + + // the event tag / event name map + Map eventTagMap = mLogParser.getTagMap(); + Map eventInfoMap = mLogParser.getEventInfoMap(); + Set keys = eventTagMap.keySet(); + ArrayList list = new ArrayList(); + for (Integer i : keys) { + if (eventInfoMap.get(i) != null) { + String eventName = eventTagMap.get(i); + mEventCombo.add(eventName); + + list.add(i); + } + } + mEventTags = list.toArray(new Integer[list.size()]); + + mEventCombo.addSelectionListener(new SelectionAdapter() { + /* (non-Javadoc) + * @see org.eclipse.swt.events.SelectionAdapter#widgetSelected(org.eclipse.swt.events.SelectionEvent) + */ + @Override + public void widgetSelected(SelectionEvent e) { + handleEventComboSelection(); + setModified(); + } + }); + + l = new Label(mShell, SWT.NONE); + l.setText("Value:"); + + mValueCombo = new Combo(mShell, SWT.DROP_DOWN | SWT.READ_ONLY); + mValueCombo.setLayoutData(new GridData(GridData.FILL_HORIZONTAL)); + mValueCombo.addSelectionListener(new SelectionAdapter() { + /* (non-Javadoc) + * @see org.eclipse.swt.events.SelectionAdapter#widgetSelected(org.eclipse.swt.events.SelectionEvent) + */ + @Override + public void widgetSelected(SelectionEvent e) { + handleValueComboSelection(); + setModified(); + } + }); + + l = new Label(mShell, SWT.NONE); + l.setText("Series Name:"); + + mSeriesCombo = new Combo(mShell, SWT.DROP_DOWN | SWT.READ_ONLY); + mSeriesCombo.setLayoutData(new GridData(GridData.FILL_HORIZONTAL)); + mSeriesCombo.addSelectionListener(new SelectionAdapter() { + /* (non-Javadoc) + * @see org.eclipse.swt.events.SelectionAdapter#widgetSelected(org.eclipse.swt.events.SelectionEvent) + */ + @Override + public void widgetSelected(SelectionEvent e) { + handleSeriesComboSelection(); + setModified(); + } + }); + + // empty comp + new Composite(mShell, SWT.NONE).setLayoutData(gd = new GridData()); + gd.heightHint = gd.widthHint = 0; + + mDisplayPidCheckBox = new Button(mShell, SWT.CHECK); + mDisplayPidCheckBox.setText("Also Show pid"); + mDisplayPidCheckBox.setEnabled(false); + mDisplayPidCheckBox.addSelectionListener(new SelectionAdapter() { + /* (non-Javadoc) + * @see org.eclipse.swt.events.SelectionAdapter#widgetSelected(org.eclipse.swt.events.SelectionEvent) + */ + @Override + public void widgetSelected(SelectionEvent e) { + mDescriptor.includePid = mDisplayPidCheckBox.getSelection(); + setModified(); + } + }); + + l = new Label(mShell, SWT.NONE); + l.setText("Filter By:"); + + mFilterCombo = new Combo(mShell, SWT.DROP_DOWN | SWT.READ_ONLY); + mFilterCombo.setLayoutData(new GridData(GridData.FILL_HORIZONTAL)); + mFilterCombo.addSelectionListener(new SelectionAdapter() { + /* (non-Javadoc) + * @see org.eclipse.swt.events.SelectionAdapter#widgetSelected(org.eclipse.swt.events.SelectionEvent) + */ + @Override + public void widgetSelected(SelectionEvent e) { + handleFilterComboSelection(); + setModified(); + } + }); + + l = new Label(mShell, SWT.NONE); + l.setText("Filter Method:"); + + mFilterMethodCombo = new Combo(mShell, SWT.DROP_DOWN | SWT.READ_ONLY); + mFilterMethodCombo.setLayoutData(new GridData(GridData.FILL_HORIZONTAL)); + for (CompareMethod method : CompareMethod.values()) { + mFilterMethodCombo.add(method.toString()); + } + mFilterMethodCombo.select(0); + mFilterMethodCombo.addSelectionListener(new SelectionAdapter() { + /* (non-Javadoc) + * @see org.eclipse.swt.events.SelectionAdapter#widgetSelected(org.eclipse.swt.events.SelectionEvent) + */ + @Override + public void widgetSelected(SelectionEvent e) { + handleFilterMethodComboSelection(); + setModified(); + } + }); + + l = new Label(mShell, SWT.NONE); + l.setText("Filter Value:"); + + mFilterValue = new Text(mShell, SWT.BORDER | SWT.SINGLE); + mFilterValue.setLayoutData(new GridData(GridData.FILL_HORIZONTAL)); + mFilterValue.addModifyListener(new ModifyListener() { + @Override + public void modifyText(ModifyEvent e) { + if (mDescriptor.filterValueIndex != -1) { + // get the current selection in the event combo + int index = mEventCombo.getSelectionIndex(); + + if (index != -1) { + // match it to an event + int eventTag = mEventTags[index]; + mDescriptor.eventTag = eventTag; + + // get the EventValueDescription for this tag + EventValueDescription valueDesc = mLogParser.getEventInfoMap() + .get(eventTag)[mDescriptor.filterValueIndex]; + + // let the EventValueDescription convert the String value into an object + // of the proper type. + mDescriptor.filterValue = valueDesc.getObjectFromString( + mFilterValue.getText().trim()); + setModified(); + } + } + } + }); + + // add a separator spanning the 2 columns + + l = new Label(mShell, SWT.SEPARATOR | SWT.HORIZONTAL); + gd = new GridData(GridData.FILL_HORIZONTAL); + gd.horizontalSpan = 2; + l.setLayoutData(gd); + + // add a composite to hold the ok/cancel button, no matter what the columns size are. + Composite buttonComp = new Composite(mShell, SWT.NONE); + gd = new GridData(GridData.FILL_HORIZONTAL); + gd.horizontalSpan = 2; + buttonComp.setLayoutData(gd); + GridLayout gl; + buttonComp.setLayout(gl = new GridLayout(6, true)); + gl.marginHeight = gl.marginWidth = 0; + + Composite padding = new Composite(mShell, SWT.NONE); + padding.setLayoutData(new GridData(GridData.FILL_HORIZONTAL)); + + mOkButton = new Button(buttonComp, SWT.PUSH); + mOkButton.setText("OK"); + mOkButton.setLayoutData(new GridData(GridData.CENTER)); + mOkButton.addSelectionListener(new SelectionAdapter() { + /* (non-Javadoc) + * @see org.eclipse.swt.events.SelectionAdapter#widgetSelected(org.eclipse.swt.events.SelectionEvent) + */ + @Override + public void widgetSelected(SelectionEvent e) { + mShell.close(); + } + }); + + padding = new Composite(mShell, SWT.NONE); + padding.setLayoutData(new GridData(GridData.FILL_HORIZONTAL)); + + padding = new Composite(mShell, SWT.NONE); + padding.setLayoutData(new GridData(GridData.FILL_HORIZONTAL)); + + Button cancelButton = new Button(buttonComp, SWT.PUSH); + cancelButton.setText("Cancel"); + cancelButton.setLayoutData(new GridData(GridData.CENTER)); + cancelButton.addSelectionListener(new SelectionAdapter() { + /* (non-Javadoc) + * @see org.eclipse.swt.events.SelectionAdapter#widgetSelected(org.eclipse.swt.events.SelectionEvent) + */ + @Override + public void widgetSelected(SelectionEvent e) { + // cancel the edit + mEditStatus = false; + mShell.close(); + } + }); + + padding = new Composite(mShell, SWT.NONE); + padding.setLayoutData(new GridData(GridData.FILL_HORIZONTAL)); + + mShell.addListener(SWT.Close, new Listener() { + @Override + public void handleEvent(Event event) { + event.doit = true; + } + }); + } + + private void setModified() { + mEditStatus = true; + } + + private void handleEventComboSelection() { + // get the current selection in the event combo + int index = mEventCombo.getSelectionIndex(); + + if (index != -1) { + // match it to an event + int eventTag = mEventTags[index]; + mDescriptor.eventTag = eventTag; + + // get the EventValueDescription for this tag + EventValueDescription[] values = mLogParser.getEventInfoMap().get(eventTag); + + // fill the combo for the values + mValueCombo.removeAll(); + if (values != null) { + if (mDescriptor instanceof ValueDisplayDescriptor) { + ValueDisplayDescriptor valueDescriptor = (ValueDisplayDescriptor)mDescriptor; + + mValueCombo.setEnabled(true); + for (EventValueDescription value : values) { + mValueCombo.add(value.toString()); + } + + if (valueDescriptor.valueIndex != -1) { + mValueCombo.select(valueDescriptor.valueIndex); + } else { + mValueCombo.clearSelection(); + } + } else { + mValueCombo.setEnabled(false); + } + + // fill the axis combo + mSeriesCombo.removeAll(); + mSeriesCombo.setEnabled(false); + mSeriesIndices.clear(); + int axisIndex = 0; + int selectionIndex = -1; + for (EventValueDescription value : values) { + if (value.getEventValueType() == EventValueType.STRING) { + mSeriesCombo.add(value.getName()); + mSeriesCombo.setEnabled(true); + mSeriesIndices.add(axisIndex); + + if (mDescriptor.seriesValueIndex != -1 && + mDescriptor.seriesValueIndex == axisIndex) { + selectionIndex = axisIndex; + } + } + axisIndex++; + } + + if (mSeriesCombo.isEnabled()) { + mSeriesCombo.add("default (pid)", 0 /* index */); + mSeriesIndices.add(0 /* index */, -1 /* value */); + + // +1 because we added another item at index 0 + mSeriesCombo.select(selectionIndex + 1); + + if (selectionIndex >= 0) { + mDisplayPidCheckBox.setSelection(mDescriptor.includePid); + mDisplayPidCheckBox.setEnabled(true); + } else { + mDisplayPidCheckBox.setEnabled(false); + mDisplayPidCheckBox.setSelection(false); + } + } else { + mDisplayPidCheckBox.setSelection(false); + mDisplayPidCheckBox.setEnabled(false); + } + + // fill the filter combo + mFilterCombo.setEnabled(true); + mFilterCombo.removeAll(); + mFilterCombo.add("(no filter)"); + for (EventValueDescription value : values) { + mFilterCombo.add(value.toString()); + } + + // select the current filter + mFilterCombo.select(mDescriptor.filterValueIndex + 1); + mFilterMethodCombo.select(getFilterMethodIndex(mDescriptor.filterCompareMethod)); + + // fill the current filter value + if (mDescriptor.filterValueIndex != -1) { + EventValueDescription valueInfo = values[mDescriptor.filterValueIndex]; + if (valueInfo.checkForType(mDescriptor.filterValue)) { + mFilterValue.setText(mDescriptor.filterValue.toString()); + } else { + mFilterValue.setText(""); + } + } else { + mFilterValue.setText(""); + } + } else { + disableSubCombos(); + } + } else { + disableSubCombos(); + } + + checkValidity(); + } + + /** + * + */ + private void disableSubCombos() { + mValueCombo.removeAll(); + mValueCombo.clearSelection(); + mValueCombo.setEnabled(false); + + mSeriesCombo.removeAll(); + mSeriesCombo.clearSelection(); + mSeriesCombo.setEnabled(false); + + mDisplayPidCheckBox.setEnabled(false); + mDisplayPidCheckBox.setSelection(false); + + mFilterCombo.removeAll(); + mFilterCombo.clearSelection(); + mFilterCombo.setEnabled(false); + + mFilterValue.setEnabled(false); + mFilterValue.setText(""); + mFilterMethodCombo.setEnabled(false); + } + + private void handleValueComboSelection() { + ValueDisplayDescriptor valueDescriptor = (ValueDisplayDescriptor)mDescriptor; + + // get the current selection in the value combo + int index = mValueCombo.getSelectionIndex(); + valueDescriptor.valueIndex = index; + + // for now set the built-in name + + // get the current selection in the event combo + int eventIndex = mEventCombo.getSelectionIndex(); + + // match it to an event + int eventTag = mEventTags[eventIndex]; + + // get the EventValueDescription for this tag + EventValueDescription[] values = mLogParser.getEventInfoMap().get(eventTag); + + valueDescriptor.valueName = values[index].getName(); + + checkValidity(); + } + + private void handleSeriesComboSelection() { + // get the current selection in the axis combo + int index = mSeriesCombo.getSelectionIndex(); + + // get the actual value index from the list. + int valueIndex = mSeriesIndices.get(index); + + mDescriptor.seriesValueIndex = valueIndex; + + if (index > 0) { + mDisplayPidCheckBox.setEnabled(true); + mDisplayPidCheckBox.setSelection(mDescriptor.includePid); + } else { + mDisplayPidCheckBox.setSelection(false); + mDisplayPidCheckBox.setEnabled(false); + } + } + + private void handleFilterComboSelection() { + // get the current selection in the axis combo + int index = mFilterCombo.getSelectionIndex(); + + // decrement index by 1 since the item 0 means + // no filter (index = -1), and the rest is offset by 1 + index--; + + mDescriptor.filterValueIndex = index; + + if (index != -1) { + mFilterValue.setEnabled(true); + mFilterMethodCombo.setEnabled(true); + if (mDescriptor.filterValue instanceof String) { + mFilterValue.setText((String)mDescriptor.filterValue); + } + } else { + mFilterValue.setText(""); + mFilterValue.setEnabled(false); + mFilterMethodCombo.setEnabled(false); + } + } + + private void handleFilterMethodComboSelection() { + // get the current selection in the axis combo + int index = mFilterMethodCombo.getSelectionIndex(); + CompareMethod method = CompareMethod.values()[index]; + + mDescriptor.filterCompareMethod = method; + } + + /** + * Returns the index of the filter method + * @param filterCompareMethod the {@link CompareMethod} enum. + */ + private int getFilterMethodIndex(CompareMethod filterCompareMethod) { + CompareMethod[] values = CompareMethod.values(); + for (int i = 0 ; i < values.length ; i++) { + if (values[i] == filterCompareMethod) { + return i; + } + } + return -1; + } + + + private void loadValueDescriptor() { + // get the index from the eventTag. + int eventIndex = 0; + int comboIndex = -1; + for (int i : mEventTags) { + if (i == mDescriptor.eventTag) { + comboIndex = eventIndex; + break; + } + eventIndex++; + } + + if (comboIndex == -1) { + mEventCombo.clearSelection(); + } else { + mEventCombo.select(comboIndex); + } + + // get the event from the descriptor + handleEventComboSelection(); + } + + private void checkValidity() { + mOkButton.setEnabled(mEventCombo.getSelectionIndex() != -1 && + (((mDescriptor instanceof ValueDisplayDescriptor) == false) || + mValueCombo.getSelectionIndex() != -1)); + } +} diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/log/event/OccurrenceRenderer.java b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/log/event/OccurrenceRenderer.java new file mode 100644 index 00000000..3af14470 --- /dev/null +++ b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/log/event/OccurrenceRenderer.java @@ -0,0 +1,90 @@ +/* + * Copyright (C) 2008 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.ddmuilib.log.event; + +import org.jfree.chart.axis.ValueAxis; +import org.jfree.chart.plot.CrosshairState; +import org.jfree.chart.plot.PlotOrientation; +import org.jfree.chart.plot.PlotRenderingInfo; +import org.jfree.chart.plot.XYPlot; +import org.jfree.chart.renderer.xy.XYItemRendererState; +import org.jfree.chart.renderer.xy.XYLineAndShapeRenderer; +import org.jfree.data.time.TimeSeriesCollection; +import org.jfree.data.xy.XYDataset; +import org.jfree.ui.RectangleEdge; + +import java.awt.Graphics2D; +import java.awt.Paint; +import java.awt.Stroke; +import java.awt.geom.Line2D; +import java.awt.geom.Rectangle2D; + +/** + * Custom renderer to render event occurrence. This rendered ignores the y value, and simply + * draws a line from min to max at the time of the item. + */ +public class OccurrenceRenderer extends XYLineAndShapeRenderer { + + private static final long serialVersionUID = 1L; + + @Override + public void drawItem(Graphics2D g2, + XYItemRendererState state, + Rectangle2D dataArea, + PlotRenderingInfo info, + XYPlot plot, + ValueAxis domainAxis, + ValueAxis rangeAxis, + XYDataset dataset, + int series, + int item, + CrosshairState crosshairState, + int pass) { + TimeSeriesCollection timeDataSet = (TimeSeriesCollection)dataset; + + // get the x value for the series/item. + double x = timeDataSet.getX(series, item).doubleValue(); + + // get the min/max of the range axis + double yMin = rangeAxis.getLowerBound(); + double yMax = rangeAxis.getUpperBound(); + + RectangleEdge domainEdge = plot.getDomainAxisEdge(); + RectangleEdge rangeEdge = plot.getRangeAxisEdge(); + + // convert the coordinates to java2d. + double x2D = domainAxis.valueToJava2D(x, dataArea, domainEdge); + double yMin2D = rangeAxis.valueToJava2D(yMin, dataArea, rangeEdge); + double yMax2D = rangeAxis.valueToJava2D(yMax, dataArea, rangeEdge); + + // get the paint information for the series/item + Paint p = getItemPaint(series, item); + Stroke s = getItemStroke(series, item); + + Line2D line = null; + PlotOrientation orientation = plot.getOrientation(); + if (orientation == PlotOrientation.HORIZONTAL) { + line = new Line2D.Double(yMin2D, x2D, yMax2D, x2D); + } + else if (orientation == PlotOrientation.VERTICAL) { + line = new Line2D.Double(x2D, yMin2D, x2D, yMax2D); + } + g2.setPaint(p); + g2.setStroke(s); + g2.draw(line); + } +} diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/log/event/SyncCommon.java b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/log/event/SyncCommon.java new file mode 100644 index 00000000..0fa6f280 --- /dev/null +++ b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/log/event/SyncCommon.java @@ -0,0 +1,173 @@ +/* + * Copyright (C) 2009 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.ddmuilib.log.event; + +import com.android.ddmlib.log.EventContainer; +import com.android.ddmlib.log.EventLogParser; +import com.android.ddmlib.log.InvalidTypeException; + +import java.awt.Color; + +abstract public class SyncCommon extends EventDisplay { + + // State information while processing the event stream + private int mLastState; // 0 if event started, 1 if event stopped + private long mLastStartTime; // ms + private long mLastStopTime; //ms + private String mLastDetails; + private int mLastSyncSource; // poll, server, user, etc. + + // Some common variables for sync display. These define the sync backends + //and how they should be displayed. + protected static final int CALENDAR = 0; + protected static final int GMAIL = 1; + protected static final int FEEDS = 2; + protected static final int CONTACTS = 3; + protected static final int ERRORS = 4; + protected static final int NUM_AUTHS = (CONTACTS + 1); + protected static final String AUTH_NAMES[] = {"Calendar", "Gmail", "Feeds", "Contacts", + "Errors"}; + protected static final Color AUTH_COLORS[] = {Color.MAGENTA, Color.GREEN, Color.BLUE, + Color.ORANGE, Color.RED}; + + // Values from data/etc/event-log-tags + final int EVENT_SYNC = 2720; + final int EVENT_TICKLE = 2742; + final int EVENT_SYNC_DETAILS = 2743; + final int EVENT_CONTACTS_AGGREGATION = 2747; + + protected SyncCommon(String name) { + super(name); + } + + /** + * Resets the display. + */ + @Override + void resetUI() { + mLastStartTime = 0; + mLastStopTime = 0; + mLastState = -1; + mLastSyncSource = -1; + mLastDetails = ""; + } + + /** + * Updates the display with a new event. This is the main entry point for + * each event. This method has the logic to tie together the start event, + * stop event, and details event into one graph item. The combined sync event + * is handed to the subclass via processSycnEvent. Note that the details + * can happen before or after the stop event. + * + * @param event The event + * @param logParser The parser providing the event. + */ + @Override + void newEvent(EventContainer event, EventLogParser logParser) { + try { + if (event.mTag == EVENT_SYNC) { + int state = Integer.parseInt(event.getValueAsString(1)); + if (state == 0) { // start + mLastStartTime = (long) event.sec * 1000L + (event.nsec / 1000000L); + mLastState = 0; + mLastSyncSource = Integer.parseInt(event.getValueAsString(2)); + mLastDetails = ""; + } else if (state == 1) { // stop + if (mLastState == 0) { + mLastStopTime = (long) event.sec * 1000L + (event.nsec / 1000000L); + if (mLastStartTime == 0) { + // Log starts with a stop event + mLastStartTime = mLastStopTime; + } + int auth = getAuth(event.getValueAsString(0)); + processSyncEvent(event, auth, mLastStartTime, mLastStopTime, mLastDetails, + true, mLastSyncSource); + mLastState = 1; + } + } + } else if (event.mTag == EVENT_SYNC_DETAILS) { + mLastDetails = event.getValueAsString(3); + if (mLastState != 0) { // Not inside event + long updateTime = (long) event.sec * 1000L + (event.nsec / 1000000L); + if (updateTime - mLastStopTime <= 250) { + // Got details within 250ms after event, so delete and re-insert + // Details later than 250ms (arbitrary) are discarded as probably + // unrelated. + int auth = getAuth(event.getValueAsString(0)); + processSyncEvent(event, auth, mLastStartTime, mLastStopTime, mLastDetails, + false, mLastSyncSource); + } + } + } else if (event.mTag == EVENT_CONTACTS_AGGREGATION) { + long stopTime = (long) event.sec * 1000L + (event.nsec / 1000000L); + long startTime = stopTime - Long.parseLong(event.getValueAsString(0)); + String details; + int count = Integer.parseInt(event.getValueAsString(1)); + if (count < 0) { + details = "g" + (-count); + } else { + details = "G" + count; + } + processSyncEvent(event, CONTACTS, startTime, stopTime, details, + true /* newEvent */, mLastSyncSource); + } + } catch (InvalidTypeException e) { + } + } + + /** + * Callback hook for subclass to process a sync event. newEvent has the logic + * to combine start and stop events and passes a processed event to the + * subclass. + * + * @param event The sync event + * @param auth The sync authority + * @param startTime Start time (ms) of events + * @param stopTime Stop time (ms) of events + * @param details Details associated with the event. + * @param newEvent True if this event is a new sync event. False if this event + * @param syncSource Poll, user, server, etc. + */ + abstract void processSyncEvent(EventContainer event, int auth, long startTime, long stopTime, + String details, boolean newEvent, int syncSource); + + /** + * Converts authority name to auth number. + * + * @param authname "calendar", etc. + * @return number series number associated with the authority + */ + protected int getAuth(String authname) throws InvalidTypeException { + if ("calendar".equals(authname) || "cl".equals(authname) || + "com.android.calendar".equals(authname)) { + return CALENDAR; + } else if ("contacts".equals(authname) || "cp".equals(authname) || + "com.android.contacts".equals(authname)) { + return CONTACTS; + } else if ("subscribedfeeds".equals(authname)) { + return FEEDS; + } else if ("gmail-ls".equals(authname) || "mail".equals(authname)) { + return GMAIL; + } else if ("gmail-live".equals(authname)) { + return GMAIL; + } else if ("unknown".equals(authname)) { + return -1; // Unknown tickles; discard + } else { + throw new InvalidTypeException("Unknown authname " + authname); + } + } +} diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/logcat/EditFilterDialog.java b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/logcat/EditFilterDialog.java new file mode 100644 index 00000000..0e302cea --- /dev/null +++ b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/logcat/EditFilterDialog.java @@ -0,0 +1,397 @@ +/* + * Copyright (C) 2007 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.ddmuilib.logcat; + +import com.android.ddmuilib.ImageLoader; + +import org.eclipse.swt.SWT; +import org.eclipse.swt.events.ModifyEvent; +import org.eclipse.swt.events.ModifyListener; +import org.eclipse.swt.events.SelectionAdapter; +import org.eclipse.swt.events.SelectionEvent; +import org.eclipse.swt.graphics.Rectangle; +import org.eclipse.swt.layout.GridData; +import org.eclipse.swt.layout.GridLayout; +import org.eclipse.swt.widgets.Button; +import org.eclipse.swt.widgets.Combo; +import org.eclipse.swt.widgets.Composite; +import org.eclipse.swt.widgets.Dialog; +import org.eclipse.swt.widgets.Display; +import org.eclipse.swt.widgets.Event; +import org.eclipse.swt.widgets.Label; +import org.eclipse.swt.widgets.Listener; +import org.eclipse.swt.widgets.Shell; +import org.eclipse.swt.widgets.Text; + +/** + * Small dialog box to edit a static port number. + */ +public class EditFilterDialog extends Dialog { + + private static final int DLG_WIDTH = 400; + private static final int DLG_HEIGHT = 260; + + private static final String IMAGE_WARNING = "warning.png"; //$NON-NLS-1$ + private static final String IMAGE_EMPTY = "empty.png"; //$NON-NLS-1$ + + private Shell mParent; + + private Shell mShell; + + private boolean mOk = false; + + /** + * Filter being edited or created + */ + private LogFilter mFilter; + + private String mName; + private String mTag; + private String mPid; + + /** Log level as an index of the drop-down combo + * @see getLogLevel + * @see getComboIndex + */ + private int mLogLevel; + + private Button mOkButton; + + private Label mNameWarning; + private Label mTagWarning; + private Label mPidWarning; + + public EditFilterDialog(Shell parent) { + super(parent, SWT.DIALOG_TRIM | SWT.BORDER | SWT.APPLICATION_MODAL); + } + + public EditFilterDialog(Shell shell, LogFilter filter) { + this(shell); + mFilter = filter; + } + + /** + * Opens the dialog. The method will return when the user closes the dialog + * somehow. + * + * @return true if ok was pressed, false if cancelled. + */ + public boolean open() { + createUI(); + + if (mParent == null || mShell == null) { + return false; + } + + mShell.setMinimumSize(DLG_WIDTH, DLG_HEIGHT); + Rectangle r = mParent.getBounds(); + // get the center new top left. + int cx = r.x + r.width/2; + int x = cx - DLG_WIDTH / 2; + int cy = r.y + r.height/2; + int y = cy - DLG_HEIGHT / 2; + mShell.setBounds(x, y, DLG_WIDTH, DLG_HEIGHT); + + mShell.open(); + + Display display = mParent.getDisplay(); + while (!mShell.isDisposed()) { + if (!display.readAndDispatch()) + display.sleep(); + } + + // we're quitting with OK. + // Lets update the filter if needed + if (mOk) { + // if it was a "Create filter" action we need to create it first. + if (mFilter == null) { + mFilter = new LogFilter(mName); + } + + // setup the filter + mFilter.setTagMode(mTag); + + if (mPid != null && mPid.length() > 0) { + mFilter.setPidMode(Integer.parseInt(mPid)); + } else { + mFilter.setPidMode(-1); + } + + mFilter.setLogLevel(getLogLevel(mLogLevel)); + } + + return mOk; + } + + public LogFilter getFilter() { + return mFilter; + } + + private void createUI() { + mParent = getParent(); + mShell = new Shell(mParent, getStyle()); + mShell.setText("Log Filter"); + + mShell.setLayout(new GridLayout(1, false)); + + mShell.addListener(SWT.Close, new Listener() { + @Override + public void handleEvent(Event event) { + } + }); + + // top part with the filter name + Composite nameComposite = new Composite(mShell, SWT.NONE); + nameComposite.setLayoutData(new GridData(GridData.FILL_BOTH)); + nameComposite.setLayout(new GridLayout(3, false)); + + Label l = new Label(nameComposite, SWT.NONE); + l.setText("Filter Name:"); + + final Text filterNameText = new Text(nameComposite, + SWT.SINGLE | SWT.BORDER); + if (mFilter != null) { + mName = mFilter.getName(); + if (mName != null) { + filterNameText.setText(mName); + } + } + filterNameText.setLayoutData(new GridData(GridData.FILL_HORIZONTAL)); + filterNameText.addModifyListener(new ModifyListener() { + @Override + public void modifyText(ModifyEvent e) { + mName = filterNameText.getText().trim(); + validate(); + } + }); + + mNameWarning = new Label(nameComposite, SWT.NONE); + mNameWarning.setImage(ImageLoader.getDdmUiLibLoader().loadImage(IMAGE_EMPTY, + mShell.getDisplay())); + + // separator + l = new Label(mShell, SWT.SEPARATOR | SWT.HORIZONTAL); + l.setLayoutData(new GridData(GridData.FILL_HORIZONTAL)); + + + // center part with the filter parameters + Composite main = new Composite(mShell, SWT.NONE); + main.setLayoutData(new GridData(GridData.FILL_BOTH)); + main.setLayout(new GridLayout(3, false)); + + l = new Label(main, SWT.NONE); + l.setText("by Log Tag:"); + + final Text tagText = new Text(main, SWT.SINGLE | SWT.BORDER); + if (mFilter != null) { + mTag = mFilter.getTagFilter(); + if (mTag != null) { + tagText.setText(mTag); + } + } + + tagText.setLayoutData(new GridData(GridData.FILL_HORIZONTAL)); + tagText.addModifyListener(new ModifyListener() { + @Override + public void modifyText(ModifyEvent e) { + mTag = tagText.getText().trim(); + validate(); + } + }); + + mTagWarning = new Label(main, SWT.NONE); + mTagWarning.setImage(ImageLoader.getDdmUiLibLoader().loadImage(IMAGE_EMPTY, + mShell.getDisplay())); + + l = new Label(main, SWT.NONE); + l.setText("by pid:"); + + final Text pidText = new Text(main, SWT.SINGLE | SWT.BORDER); + if (mFilter != null) { + if (mFilter.getPidFilter() != -1) { + mPid = Integer.toString(mFilter.getPidFilter()); + } else { + mPid = ""; + } + pidText.setText(mPid); + } + pidText.setLayoutData(new GridData(GridData.FILL_HORIZONTAL)); + pidText.addModifyListener(new ModifyListener() { + @Override + public void modifyText(ModifyEvent e) { + mPid = pidText.getText().trim(); + validate(); + } + }); + + mPidWarning = new Label(main, SWT.NONE); + mPidWarning.setImage(ImageLoader.getDdmUiLibLoader().loadImage(IMAGE_EMPTY, + mShell.getDisplay())); + + l = new Label(main, SWT.NONE); + l.setText("by Log level:"); + + final Combo logCombo = new Combo(main, SWT.DROP_DOWN | SWT.READ_ONLY); + GridData gd = new GridData(GridData.FILL_HORIZONTAL); + gd.horizontalSpan = 2; + logCombo.setLayoutData(gd); + + // add the labels + logCombo.add(""); + logCombo.add("Error"); + logCombo.add("Warning"); + logCombo.add("Info"); + logCombo.add("Debug"); + logCombo.add("Verbose"); + + if (mFilter != null) { + mLogLevel = getComboIndex(mFilter.getLogLevel()); + logCombo.select(mLogLevel); + } else { + logCombo.select(0); + } + + logCombo.addSelectionListener(new SelectionAdapter() { + @Override + public void widgetSelected(SelectionEvent e) { + // get the selection + mLogLevel = logCombo.getSelectionIndex(); + validate(); + } + }); + + // separator + l = new Label(mShell, SWT.SEPARATOR | SWT.HORIZONTAL); + l.setLayoutData(new GridData(GridData.FILL_HORIZONTAL)); + + // bottom part with the ok/cancel + Composite bottomComp = new Composite(mShell, SWT.NONE); + bottomComp + .setLayoutData(new GridData(GridData.HORIZONTAL_ALIGN_CENTER)); + bottomComp.setLayout(new GridLayout(2, true)); + + mOkButton = new Button(bottomComp, SWT.NONE); + mOkButton.setText("OK"); + mOkButton.addSelectionListener(new SelectionAdapter() { + @Override + public void widgetSelected(SelectionEvent e) { + mOk = true; + mShell.close(); + } + }); + mOkButton.setEnabled(false); + mShell.setDefaultButton(mOkButton); + + Button cancelButton = new Button(bottomComp, SWT.NONE); + cancelButton.setText("Cancel"); + cancelButton.addSelectionListener(new SelectionAdapter() { + @Override + public void widgetSelected(SelectionEvent e) { + mShell.close(); + } + }); + + validate(); + } + + /** + * Returns the log level from a combo index. + * @param index the Combo index + * @return a log level valid for the Log class. + */ + protected int getLogLevel(int index) { + if (index == 0) { + return -1; + } + + return 7 - index; + } + + /** + * Returns the index in the combo that matches the log level + * @param logLevel The Log level. + * @return the combo index + */ + private int getComboIndex(int logLevel) { + if (logLevel == -1) { + return 0; + } + + return 7 - logLevel; + } + + /** + * Validates the content of the 2 text fields and enable/disable "ok", while + * setting up the warning/error message. + */ + private void validate() { + + boolean result = true; + + // then we check it only contains digits. + if (mPid != null) { + if (mPid.matches("[0-9]*") == false) { //$NON-NLS-1$ + mPidWarning.setImage(ImageLoader.getDdmUiLibLoader().loadImage( + IMAGE_WARNING, + mShell.getDisplay())); + mPidWarning.setToolTipText("PID must be a number"); //$NON-NLS-1$ + result = false; + } else { + mPidWarning.setImage(ImageLoader.getDdmUiLibLoader().loadImage( + IMAGE_EMPTY, + mShell.getDisplay())); + mPidWarning.setToolTipText(null); + } + } + + // then we check it not contains character | or : + if (mTag != null) { + if (mTag.matches(".*[:|].*") == true) { //$NON-NLS-1$ + mTagWarning.setImage(ImageLoader.getDdmUiLibLoader().loadImage( + IMAGE_WARNING, + mShell.getDisplay())); + mTagWarning.setToolTipText("Tag cannot contain | or :"); //$NON-NLS-1$ + result = false; + } else { + mTagWarning.setImage(ImageLoader.getDdmUiLibLoader().loadImage( + IMAGE_EMPTY, + mShell.getDisplay())); + mTagWarning.setToolTipText(null); + } + } + + // then we check it not contains character | or : + if (mName != null && mName.length() > 0) { + if (mName.matches(".*[:|].*") == true) { //$NON-NLS-1$ + mNameWarning.setImage(ImageLoader.getDdmUiLibLoader().loadImage( + IMAGE_WARNING, + mShell.getDisplay())); + mNameWarning.setToolTipText("Name cannot contain | or :"); //$NON-NLS-1$ + result = false; + } else { + mNameWarning.setImage(ImageLoader.getDdmUiLibLoader().loadImage( + IMAGE_EMPTY, + mShell.getDisplay())); + mNameWarning.setToolTipText(null); + } + } else { + result = false; + } + + mOkButton.setEnabled(result); + } +} diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/logcat/ILogCatMessageEventListener.java b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/logcat/ILogCatMessageEventListener.java new file mode 100644 index 00000000..2caf50d2 --- /dev/null +++ b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/logcat/ILogCatMessageEventListener.java @@ -0,0 +1,29 @@ +/* + * Copyright (C) 2011 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.ddmuilib.logcat; + +import java.util.List; + +/** + * Listeners interested in log cat messages should implement this interface. + */ +public interface ILogCatMessageEventListener { + /** Called on reception of logcat messages. + * @param receivedMessages list of messages received + */ + void messageReceived(List receivedMessages); +} diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/logcat/ILogCatMessageSelectionListener.java b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/logcat/ILogCatMessageSelectionListener.java new file mode 100644 index 00000000..6e814b0a --- /dev/null +++ b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/logcat/ILogCatMessageSelectionListener.java @@ -0,0 +1,24 @@ +/* + * Copyright (C) 2011 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.ddmuilib.logcat; + +/** + * Classes interested in listening to user selection of logcat + * messages should implement this interface. + */ +public interface ILogCatMessageSelectionListener { + void messageDoubleClicked(LogCatMessage m); +} diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/logcat/LogCatFilter.java b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/logcat/LogCatFilter.java new file mode 100644 index 00000000..509449df --- /dev/null +++ b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/logcat/LogCatFilter.java @@ -0,0 +1,286 @@ +/* + * Copyright (C) 2011 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.ddmuilib.logcat; + +import com.android.ddmlib.Log; +import com.android.ddmlib.Log.LogLevel; + +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.regex.PatternSyntaxException; + +/** + * A Filter for logcat messages. A filter can be constructed to match + * different fields of a logcat message. It can then be queried to see if + * a message matches the filter's settings. + */ +public final class LogCatFilter { + private static final String PID_KEYWORD = "pid:"; //$NON-NLS-1$ + private static final String APP_KEYWORD = "app:"; //$NON-NLS-1$ + private static final String TAG_KEYWORD = "tag:"; //$NON-NLS-1$ + private static final String TEXT_KEYWORD = "text:"; //$NON-NLS-1$ + + private final String mName; + private final String mTag; + private final String mText; + private final String mPid; + private final String mAppName; + private final LogLevel mLogLevel; + + /** Indicates the number of messages that match this filter, but have not + * yet been read by the user. This is really metadata about this filter + * necessary for the UI. If we ever end up needing to store more metadata, + * then it is probably better to move it out into a separate class. */ + private int mUnreadCount; + + /** Indicates that this filter is transient, and should not be persisted + * across Eclipse sessions. */ + private boolean mTransient; + + private boolean mCheckPid; + private boolean mCheckAppName; + private boolean mCheckTag; + private boolean mCheckText; + + private Pattern mAppNamePattern; + private Pattern mTagPattern; + private Pattern mTextPattern; + + /** + * Construct a filter with the provided restrictions for the logcat message. All the text + * fields accept Java regexes as input, but ignore invalid regexes. Filters are saved and + * restored across Eclipse sessions unless explicitly marked transient using + * {@link LogCatFilter#setTransient}. + * @param name name for the filter + * @param tag value for the logcat message's tag field. + * @param text value for the logcat message's text field. + * @param pid value for the logcat message's pid field. + * @param appName value for the logcat message's app name field. + * @param logLevel value for the logcat message's log level. Only messages of + * higher priority will be accepted by the filter. + */ + public LogCatFilter(String name, String tag, String text, String pid, String appName, + LogLevel logLevel) { + mName = name.trim(); + mTag = tag.trim(); + mText = text.trim(); + mPid = pid.trim(); + mAppName = appName.trim(); + mLogLevel = logLevel; + + mUnreadCount = 0; + + // By default, all filters are persistent. Transient filters should explicitly + // mark it so by calling setTransient. + mTransient = false; + + mCheckPid = mPid.length() != 0; + + if (mAppName.length() != 0) { + try { + mAppNamePattern = Pattern.compile(mAppName, getPatternCompileFlags(mAppName)); + mCheckAppName = true; + } catch (PatternSyntaxException e) { + Log.e("LogCatFilter", "Ignoring invalid app name regex."); + Log.e("LogCatFilter", e.getMessage()); + mCheckAppName = false; + } + } + + if (mTag.length() != 0) { + try { + mTagPattern = Pattern.compile(mTag, getPatternCompileFlags(mTag)); + mCheckTag = true; + } catch (PatternSyntaxException e) { + Log.e("LogCatFilter", "Ignoring invalid tag regex."); + Log.e("LogCatFilter", e.getMessage()); + mCheckTag = false; + } + } + + if (mText.length() != 0) { + try { + mTextPattern = Pattern.compile(mText, getPatternCompileFlags(mText)); + mCheckText = true; + } catch (PatternSyntaxException e) { + Log.e("LogCatFilter", "Ignoring invalid text regex."); + Log.e("LogCatFilter", e.getMessage()); + mCheckText = false; + } + } + } + + /** + * Obtain the flags to pass to {@link Pattern#compile(String, int)}. This method + * tries to figure out whether case sensitive matching should be used. It is based on + * the following heuristic: if the regex has an upper case character, then the match + * will be case sensitive. Otherwise it will be case insensitive. + */ + private int getPatternCompileFlags(String regex) { + for (char c : regex.toCharArray()) { + if (Character.isUpperCase(c)) { + return 0; + } + } + + return Pattern.CASE_INSENSITIVE; + } + + /** + * Construct a list of {@link LogCatFilter} objects by decoding the query. + * @param query encoded search string. The query is simply a list of words (can be regexes) + * a user would type in a search bar. These words are searched for in the text field of + * each collected logcat message. To search in a different field, the word could be prefixed + * with a keyword corresponding to the field name. Currently, the following keywords are + * supported: "pid:", "tag:" and "text:". Invalid regexes are ignored. + * @param minLevel minimum log level to match + * @return list of filter settings that fully match the given query + */ + public static List fromString(String query, LogLevel minLevel) { + List filterSettings = new ArrayList(); + + for (String s : query.trim().split(" ")) { + String tag = ""; + String text = ""; + String pid = ""; + String app = ""; + + if (s.startsWith(PID_KEYWORD)) { + pid = s.substring(PID_KEYWORD.length()); + } else if (s.startsWith(APP_KEYWORD)) { + app = s.substring(APP_KEYWORD.length()); + } else if (s.startsWith(TAG_KEYWORD)) { + tag = s.substring(TAG_KEYWORD.length()); + } else { + if (s.startsWith(TEXT_KEYWORD)) { + text = s.substring(TEXT_KEYWORD.length()); + } else { + text = s; + } + } + filterSettings.add(new LogCatFilter("livefilter-" + s, + tag, text, pid, app, minLevel)); + } + + return filterSettings; + } + + public String getName() { + return mName; + } + + public String getTag() { + return mTag; + } + + public String getText() { + return mText; + } + + public String getPid() { + return mPid; + } + + public String getAppName() { + return mAppName; + } + + public LogLevel getLogLevel() { + return mLogLevel; + } + + /** + * Check whether a given message will make it through this filter. + * @param m message to check + * @return true if the message matches the filter's conditions. + */ + public boolean matches(LogCatMessage m) { + /* filter out messages of a lower priority */ + if (m.getLogLevel().getPriority() < mLogLevel.getPriority()) { + return false; + } + + /* if pid filter is enabled, filter out messages whose pid does not match + * the filter's pid */ + if (mCheckPid && !m.getPid().equals(mPid)) { + return false; + } + + /* if app name filter is enabled, filter out messages not matching the app name */ + if (mCheckAppName) { + Matcher matcher = mAppNamePattern.matcher(m.getAppName()); + if (!matcher.find()) { + return false; + } + } + + /* if tag filter is enabled, filter out messages not matching the tag */ + if (mCheckTag) { + Matcher matcher = mTagPattern.matcher(m.getTag()); + if (!matcher.find()) { + return false; + } + } + + if (mCheckText) { + Matcher matcher = mTextPattern.matcher(m.getMessage()); + if (!matcher.find()) { + return false; + } + } + + return true; + } + + /** + * Update the unread count based on new messages received. The unread count + * is incremented by the count of messages in the received list that will be + * accepted by this filter. + * @param newMessages list of new messages. + */ + public void updateUnreadCount(List newMessages) { + for (LogCatMessage m : newMessages) { + if (matches(m)) { + mUnreadCount++; + } + } + } + + /** + * Reset count of unread messages. + */ + public void resetUnreadCount() { + mUnreadCount = 0; + } + + /** + * Get current value for the unread message counter. + */ + public int getUnreadCount() { + return mUnreadCount; + } + + /** Make this filter transient: It will not be persisted across sessions. */ + public void setTransient() { + mTransient = true; + } + + public boolean isTransient() { + return mTransient; + } +} diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/logcat/LogCatFilterContentProvider.java b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/logcat/LogCatFilterContentProvider.java new file mode 100644 index 00000000..164f4847 --- /dev/null +++ b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/logcat/LogCatFilterContentProvider.java @@ -0,0 +1,48 @@ +/* + * Copyright (C) 2011 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.ddmuilib.logcat; + +import org.eclipse.jface.viewers.IStructuredContentProvider; +import org.eclipse.jface.viewers.Viewer; + +import java.util.List; + +/** + * A JFace content provider for logcat filter list, used in {@link LogCatPanel}. + */ +public final class LogCatFilterContentProvider implements IStructuredContentProvider { + @Override + public void dispose() { + } + + @Override + public void inputChanged(Viewer arg0, Object arg1, Object arg2) { + } + + /** + * Obtain the list of filters currently in use. + * @param model list of {@link LogCatFilter}'s + * @return array of {@link LogCatFilter} objects, or null. + */ + @Override + public Object[] getElements(Object model) { + if (model instanceof List) { + return ((List) model).toArray(); + } + return null; + } + +} diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/logcat/LogCatFilterLabelProvider.java b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/logcat/LogCatFilterLabelProvider.java new file mode 100644 index 00000000..59e236c9 --- /dev/null +++ b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/logcat/LogCatFilterLabelProvider.java @@ -0,0 +1,52 @@ +/* + * Copyright (C) 2011 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.ddmuilib.logcat; + +import org.eclipse.jface.viewers.ITableLabelProvider; +import org.eclipse.jface.viewers.LabelProvider; +import org.eclipse.swt.graphics.Image; + +/** + * A JFace label provider for the LogCat filters. It expects elements of type + * {@link LogCatFilter}. + */ +public final class LogCatFilterLabelProvider extends LabelProvider implements ITableLabelProvider { + @Override + public Image getColumnImage(Object arg0, int arg1) { + return null; + } + + /** + * Implements {@link ITableLabelProvider#getColumnText(Object, int)}. + * @param element an instance of {@link LogCatFilter} + * @param index index of the column + * @return text to use in the column + */ + @Override + public String getColumnText(Object element, int index) { + if (!(element instanceof LogCatFilter)) { + return null; + } + + LogCatFilter f = (LogCatFilter) element; + + if (f.getUnreadCount() == 0) { + return f.getName(); + } else { + return String.format("%s (%d)", f.getName(), f.getUnreadCount()); + } + } +} diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/logcat/LogCatFilterSettingsDialog.java b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/logcat/LogCatFilterSettingsDialog.java new file mode 100644 index 00000000..f68ee059 --- /dev/null +++ b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/logcat/LogCatFilterSettingsDialog.java @@ -0,0 +1,327 @@ +/* + * Copyright (C) 2011 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.ddmuilib.logcat; + +import com.android.ddmlib.Log.LogLevel; + +import org.eclipse.jface.dialogs.IDialogConstants; +import org.eclipse.jface.dialogs.TitleAreaDialog; +import org.eclipse.swt.SWT; +import org.eclipse.swt.events.ModifyEvent; +import org.eclipse.swt.events.ModifyListener; +import org.eclipse.swt.layout.GridData; +import org.eclipse.swt.layout.GridLayout; +import org.eclipse.swt.widgets.Button; +import org.eclipse.swt.widgets.Combo; +import org.eclipse.swt.widgets.Composite; +import org.eclipse.swt.widgets.Control; +import org.eclipse.swt.widgets.Label; +import org.eclipse.swt.widgets.Shell; +import org.eclipse.swt.widgets.Text; + +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Pattern; +import java.util.regex.PatternSyntaxException; + +/** + * Dialog used to create or edit settings for a logcat filter. + */ +public final class LogCatFilterSettingsDialog extends TitleAreaDialog { + private static final String TITLE = "Logcat Message Filter Settings"; + private static final String DEFAULT_MESSAGE = + "Filter logcat messages by the source's tag, pid or minimum log level.\n" + + "Empty fields will match all messages."; + + private String mFilterName; + private String mTag; + private String mText; + private String mPid; + private String mAppName; + private String mLogLevel; + + private Text mFilterNameText; + private Text mTagFilterText; + private Text mTextFilterText; + private Text mPidFilterText; + private Text mAppNameFilterText; + private Combo mLogLevelCombo; + private Button mOkButton; + + /** + * Construct the filter settings dialog with default values for all fields. + * @param parentShell . + */ + public LogCatFilterSettingsDialog(Shell parentShell) { + super(parentShell); + setDefaults("", "", "", "", "", LogLevel.VERBOSE); + } + + /** + * Set the default values to show when the dialog is opened. + * @param filterName name for the filter. + * @param tag value for filter by tag + * @param text value for filter by text + * @param pid value for filter by pid + * @param appName value for filter by app name + * @param level value for filter by log level + */ + public void setDefaults(String filterName, String tag, String text, String pid, String appName, + LogLevel level) { + mFilterName = filterName; + mTag = tag; + mText = text; + mPid = pid; + mAppName = appName; + mLogLevel = level.getStringValue(); + } + + @Override + protected Control createDialogArea(Composite shell) { + setTitle(TITLE); + setMessage(DEFAULT_MESSAGE); + + Composite parent = (Composite) super.createDialogArea(shell); + Composite c = new Composite(parent, SWT.BORDER); + c.setLayout(new GridLayout(2, false)); + c.setLayoutData(new GridData(GridData.FILL_BOTH)); + + createLabel(c, "Filter Name:"); + mFilterNameText = new Text(c, SWT.BORDER); + mFilterNameText.setLayoutData(new GridData(GridData.FILL_HORIZONTAL)); + mFilterNameText.setText(mFilterName); + + createSeparator(c); + + createLabel(c, "by Log Tag:"); + mTagFilterText = new Text(c, SWT.BORDER); + mTagFilterText.setLayoutData(new GridData(GridData.FILL_HORIZONTAL)); + mTagFilterText.setText(mTag); + + createLabel(c, "by Log Message:"); + mTextFilterText = new Text(c, SWT.BORDER); + mTextFilterText.setLayoutData(new GridData(GridData.FILL_HORIZONTAL)); + mTextFilterText.setText(mText); + + createLabel(c, "by PID:"); + mPidFilterText = new Text(c, SWT.BORDER); + mPidFilterText.setLayoutData(new GridData(GridData.FILL_HORIZONTAL)); + mPidFilterText.setText(mPid); + + createLabel(c, "by Application Name:"); + mAppNameFilterText = new Text(c, SWT.BORDER); + mAppNameFilterText.setLayoutData(new GridData(GridData.FILL_HORIZONTAL)); + mAppNameFilterText.setText(mAppName); + + createLabel(c, "by Log Level:"); + mLogLevelCombo = new Combo(c, SWT.READ_ONLY | SWT.DROP_DOWN); + mLogLevelCombo.setItems(getLogLevels().toArray(new String[0])); + mLogLevelCombo.select(getLogLevels().indexOf(mLogLevel)); + + /* call validateDialog() whenever user modifies any text field */ + ModifyListener m = new ModifyListener() { + @Override + public void modifyText(ModifyEvent arg0) { + DialogStatus status = validateDialog(); + mOkButton.setEnabled(status.valid); + setErrorMessage(status.message); + } + }; + mFilterNameText.addModifyListener(m); + mTagFilterText.addModifyListener(m); + mTextFilterText.addModifyListener(m); + mPidFilterText.addModifyListener(m); + mAppNameFilterText.addModifyListener(m); + + return c; + } + + + @Override + protected void createButtonsForButtonBar(Composite parent) { + super.createButtonsForButtonBar(parent); + + mOkButton = getButton(IDialogConstants.OK_ID); + + DialogStatus status = validateDialog(); + mOkButton.setEnabled(status.valid); + } + + /** + * A tuple that specifies whether the current state of the inputs + * on the dialog is valid or not. If it is not valid, the message + * field stores the reason why it isn't. + */ + private final class DialogStatus { + final boolean valid; + final String message; + + private DialogStatus(boolean isValid, String errMessage) { + valid = isValid; + message = errMessage; + } + } + + private DialogStatus validateDialog() { + /* check that there is some name for the filter */ + if (mFilterNameText.getText().trim().equals("")) { + return new DialogStatus(false, + "Please provide a name for this filter."); + } + + /* if a pid is provided, it should be a +ve integer */ + String pidText = mPidFilterText.getText().trim(); + if (pidText.trim().length() > 0) { + int pid = 0; + try { + pid = Integer.parseInt(pidText); + } catch (NumberFormatException e) { + return new DialogStatus(false, + "PID should be a positive integer."); + } + + if (pid < 0) { + return new DialogStatus(false, + "PID should be a positive integer."); + } + } + + /* tag field must use a valid regex pattern */ + String tagText = mTagFilterText.getText().trim(); + if (tagText.trim().length() > 0) { + try { + Pattern.compile(tagText); + } catch (PatternSyntaxException e) { + return new DialogStatus(false, + "Invalid regex used in tag field: " + e.getMessage()); + } + } + + /* text field must use a valid regex pattern */ + String messageText = mTextFilterText.getText().trim(); + if (messageText.trim().length() > 0) { + try { + Pattern.compile(messageText); + } catch (PatternSyntaxException e) { + return new DialogStatus(false, + "Invalid regex used in text field: " + e.getMessage()); + } + } + + /* app name field must use a valid regex pattern */ + String appNameText = mAppNameFilterText.getText().trim(); + if (appNameText.trim().length() > 0) { + try { + Pattern.compile(appNameText); + } catch (PatternSyntaxException e) { + return new DialogStatus(false, + "Invalid regex used in application name field: " + e.getMessage()); + } + } + + return new DialogStatus(true, null); + } + + private void createSeparator(Composite c) { + Label l = new Label(c, SWT.SEPARATOR | SWT.HORIZONTAL); + GridData gd = new GridData(GridData.FILL_HORIZONTAL); + gd.horizontalSpan = 2; + l.setLayoutData(gd); + } + + private void createLabel(Composite c, String text) { + Label l = new Label(c, SWT.NONE); + l.setText(text); + GridData gd = new GridData(); + gd.horizontalAlignment = SWT.RIGHT; + l.setLayoutData(gd); + } + + @Override + protected void okPressed() { + /* save values from the widgets before the shell is closed. */ + mFilterName = mFilterNameText.getText(); + mTag = mTagFilterText.getText(); + mText = mTextFilterText.getText(); + mLogLevel = mLogLevelCombo.getText(); + mPid = mPidFilterText.getText(); + mAppName = mAppNameFilterText.getText(); + + super.okPressed(); + } + + /** + * Obtain the name for this filter. + * @return user provided filter name, maybe empty. + */ + public String getFilterName() { + return mFilterName; + } + + /** + * Obtain the tag regex to filter by. + * @return user provided tag regex, maybe empty. + */ + public String getTag() { + return mTag; + } + + /** + * Obtain the text regex to filter by. + * @return user provided tag regex, maybe empty. + */ + public String getText() { + return mText; + } + + /** + * Obtain user provided PID to filter by. + * @return user provided pid, maybe empty. + */ + public String getPid() { + return mPid; + } + + /** + * Obtain user provided application name to filter by. + * @return user provided app name regex, maybe empty + */ + public String getAppName() { + return mAppName; + } + + /** + * Obtain log level to filter by. + * @return log level string. + */ + public String getLogLevel() { + return mLogLevel; + } + + /** + * Obtain the string representation of all supported log levels. + * @return an array of strings, each representing a certain log level. + */ + public static List getLogLevels() { + List logLevels = new ArrayList(); + + for (LogLevel l : LogLevel.values()) { + logLevels.add(l.getStringValue()); + } + + return logLevels; + } +} diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/logcat/LogCatFilterSettingsSerializer.java b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/logcat/LogCatFilterSettingsSerializer.java new file mode 100644 index 00000000..12fbdfab --- /dev/null +++ b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/logcat/LogCatFilterSettingsSerializer.java @@ -0,0 +1,206 @@ +/* + * Copyright (C) 2011 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.ddmuilib.logcat; + +import com.android.ddmlib.Log.LogLevel; + +import java.util.ArrayList; +import java.util.List; + +/** + * Class to help save/restore user created filters. + * + * Users can create multiple filters in the logcat view. These filters could have regexes + * in their settings. All of the user created filters are saved into a single Eclipse + * preference. This class helps in generating the string to be saved given a list of + * {@link LogCatFilter}'s, and also does the reverse of creating the list of filters + * given the encoded string. + */ +public final class LogCatFilterSettingsSerializer { + private static final char SINGLE_QUOTE = '\''; + private static final char ESCAPE_CHAR = '\\'; + + private static final String ATTR_DELIM = ", "; + private static final String KW_DELIM = ": "; + + private static final String KW_NAME = "name"; + private static final String KW_TAG = "tag"; + private static final String KW_TEXT = "text"; + private static final String KW_PID = "pid"; + private static final String KW_APP = "app"; + private static final String KW_LOGLEVEL = "level"; + + /** + * Encode the settings from a list of {@link LogCatFilter}'s into a string for saving to + * the preference store. See + * {@link LogCatFilterSettingsSerializer#decodeFromPreferenceString(String)} for the + * reverse operation. + * @param filters list of filters to save. + * @return an encoded string that can be saved in Eclipse preference store. The encoded string + * is of a list of key:'value' pairs. + */ + public String encodeToPreferenceString(List filters) { + StringBuffer sb = new StringBuffer(); + + for (LogCatFilter f : filters) { + if (f.isTransient()) { + // do not persist transient filters + continue; + } + + sb.append(KW_NAME); sb.append(KW_DELIM); sb.append(quoteString(f.getName())); + sb.append(ATTR_DELIM); + sb.append(KW_TAG); sb.append(KW_DELIM); sb.append(quoteString(f.getTag())); + sb.append(ATTR_DELIM); + sb.append(KW_TEXT); sb.append(KW_DELIM); sb.append(quoteString(f.getText())); + sb.append(ATTR_DELIM); + sb.append(KW_PID); sb.append(KW_DELIM); sb.append(quoteString(f.getPid())); + sb.append(ATTR_DELIM); + sb.append(KW_APP); sb.append(KW_DELIM); sb.append(quoteString(f.getAppName())); + sb.append(ATTR_DELIM); + sb.append(KW_LOGLEVEL); sb.append(KW_DELIM); + sb.append(quoteString(f.getLogLevel().getStringValue())); + sb.append(ATTR_DELIM); + } + return sb.toString(); + } + + /** + * Decode an encoded string representing the settings of a list of logcat + * filters into a list of {@link LogCatFilter}'s. + * @param pref encoded preference string + * @return a list of {@link LogCatFilter} + */ + public List decodeFromPreferenceString(String pref) { + List fs = new ArrayList(); + + /* first split the string into a list of key, value pairs */ + List kv = getKeyValues(pref); + if (kv.size() == 0) { + return fs; + } + + /* construct filter settings from the key value pairs */ + int index = 0; + while (index < kv.size()) { + String name = ""; + String tag = ""; + String pid = ""; + String app = ""; + String text = ""; + LogLevel level = LogLevel.VERBOSE; + + assert kv.get(index).equals(KW_NAME); + name = kv.get(index + 1); + + index += 2; + while (index < kv.size() && !kv.get(index).equals(KW_NAME)) { + String key = kv.get(index); + String value = kv.get(index + 1); + index += 2; + + if (key.equals(KW_TAG)) { + tag = value; + } else if (key.equals(KW_TEXT)) { + text = value; + } else if (key.equals(KW_PID)) { + pid = value; + } else if (key.equals(KW_APP)) { + app = value; + } else if (key.equals(KW_LOGLEVEL)) { + level = LogLevel.getByString(value); + } + } + + fs.add(new LogCatFilter(name, tag, text, pid, app, level)); + } + + return fs; + } + + private List getKeyValues(String pref) { + List kv = new ArrayList(); + int index = 0; + while (index < pref.length()) { + String kw = getKeyword(pref.substring(index)); + if (kw == null) { + break; + } + index += kw.length() + KW_DELIM.length(); + + String value = getNextString(pref.substring(index)); + index += value.length() + ATTR_DELIM.length(); + + value = unquoteString(value); + + kv.add(kw); + kv.add(value); + } + + return kv; + } + + /** + * Enclose a string in quotes, escaping all the quotes within the string. + */ + private String quoteString(String s) { + return SINGLE_QUOTE + s.replace(Character.toString(SINGLE_QUOTE), "\\'") + + SINGLE_QUOTE; + } + + /** + * Recover original string from its escaped version created using + * {@link LogCatFilterSettingsSerializer#quoteString(String)}. + */ + private String unquoteString(String s) { + s = s.substring(1, s.length() - 1); /* remove start and end QUOTES */ + return s.replace("\\'", Character.toString(SINGLE_QUOTE)); + } + + private String getKeyword(String pref) { + int kwlen = pref.indexOf(KW_DELIM); + if (kwlen == -1) { + return null; + } + + return pref.substring(0, kwlen); + } + + /** + * Get the next quoted string from the input stream of characters. + */ + private String getNextString(String s) { + assert s.charAt(0) == SINGLE_QUOTE; + + StringBuffer sb = new StringBuffer(); + + int index = 0; + while (index < s.length()) { + sb.append(s.charAt(index)); + + if (index > 0 + && s.charAt(index) == SINGLE_QUOTE // current char is a single quote + && s.charAt(index - 1) != ESCAPE_CHAR) { // prev char wasn't a backslash + /* break if an unescaped SINGLE QUOTE (end of string) is seen */ + break; + } + + index++; + } + + return sb.toString(); + } +} diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/logcat/LogCatMessage.java b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/logcat/LogCatMessage.java new file mode 100644 index 00000000..aea4ead9 --- /dev/null +++ b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/logcat/LogCatMessage.java @@ -0,0 +1,96 @@ +/* + * Copyright (C) 2011 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.ddmuilib.logcat; + +import com.android.ddmlib.Log.LogLevel; + +/** + * Model a single log message output from {@code logcat -v long}. + * A logcat message has a {@link LogLevel}, the pid (process id) of the process + * generating the message, the time at which the message was generated, and + * the tag and message itself. + */ +public final class LogCatMessage { + private final LogLevel mLogLevel; + private final String mPid; + private final String mTid; + private final String mAppName; + private final String mTag; + private final String mTime; + private final String mMessage; + + /** + * Construct an immutable log message object. + */ + public LogCatMessage(LogLevel logLevel, String pid, String tid, String appName, + String tag, String time, String msg) { + mLogLevel = logLevel; + mPid = pid; + mAppName = appName; + mTag = tag; + mTime = time; + mMessage = msg; + + long tidValue; + try { + // Thread id's may be in hex on some platforms. + // Decode and store them in radix 10. + tidValue = Long.decode(tid.trim()); + } catch (NumberFormatException e) { + tidValue = -1; + } + + mTid = Long.toString(tidValue); + } + + public LogLevel getLogLevel() { + return mLogLevel; + } + + public String getPid() { + return mPid; + } + + public String getTid() { + return mTid; + } + + public String getAppName() { + return mAppName; + } + + public String getTag() { + return mTag; + } + + public String getTime() { + return mTime; + } + + public String getMessage() { + return mMessage; + } + + @Override + public String toString() { + return mTime + ": " + + mLogLevel.getPriorityLetter() + "/" + + mTag + "(" + + mPid + "): " + + mMessage; + } +} diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/logcat/LogCatMessageContentProvider.java b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/logcat/LogCatMessageContentProvider.java new file mode 100644 index 00000000..bd7b520b --- /dev/null +++ b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/logcat/LogCatMessageContentProvider.java @@ -0,0 +1,43 @@ +/* + * Copyright (C) 2011 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.ddmuilib.logcat; + +import org.eclipse.jface.viewers.IStructuredContentProvider; +import org.eclipse.jface.viewers.Viewer; + +/** + * A JFace content provider for the LogCat log messages, used in the {@link LogCatPanel}. + */ +public final class LogCatMessageContentProvider implements IStructuredContentProvider { + @Override + public void dispose() { + } + + @Override + public void inputChanged(Viewer viewer, Object oldInput, Object newInput) { + } + + @Override + public Object[] getElements(Object model) { + if (model instanceof LogCatMessageList) { + Object[] e = ((LogCatMessageList) model).toArray(); + return e; + } + + return null; + } +} diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/logcat/LogCatMessageLabelProvider.java b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/logcat/LogCatMessageLabelProvider.java new file mode 100644 index 00000000..1d83a9c0 --- /dev/null +++ b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/logcat/LogCatMessageLabelProvider.java @@ -0,0 +1,144 @@ +/* + * Copyright (C) 2011 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.ddmuilib.logcat; + +import com.android.ddmlib.Log.LogLevel; + +import org.eclipse.jface.viewers.ColumnLabelProvider; +import org.eclipse.jface.viewers.ViewerCell; +import org.eclipse.swt.graphics.Color; +import org.eclipse.swt.graphics.Font; +import org.eclipse.swt.graphics.Point; + +/** + * A JFace Column label provider for the LogCat log messages. It expects elements of type + * {@link LogCatMessage}. + */ +public final class LogCatMessageLabelProvider extends ColumnLabelProvider { + private static final int INDEX_LOGLEVEL = 0; + private static final int INDEX_LOGTIME = 1; + private static final int INDEX_PID = 2; + private static final int INDEX_APPNAME = 3; + private static final int INDEX_TAG = 4; + private static final int INDEX_TEXT = 5; + + /* Default Colors for different log levels. */ + private static final Color INFO_MSG_COLOR = new Color(null, 0, 127, 0); + private static final Color DEBUG_MSG_COLOR = new Color(null, 0, 0, 127); + private static final Color ERROR_MSG_COLOR = new Color(null, 255, 0, 0); + private static final Color WARN_MSG_COLOR = new Color(null, 255, 127, 0); + private static final Color VERBOSE_MSG_COLOR = new Color(null, 0, 0, 0); + + /** Amount of pixels to shift the tooltip by. */ + private static final Point LOGCAT_TOOLTIP_SHIFT = new Point(10, 10); + + private Font mLogFont; + private int mWrapWidth = 100; + + /** + * Construct a column label provider for the logcat table. + * @param font default font to use + */ + public LogCatMessageLabelProvider(Font font) { + mLogFont = font; + } + + private String getCellText(LogCatMessage m, int columnIndex) { + switch (columnIndex) { + case INDEX_LOGLEVEL: + return Character.toString(m.getLogLevel().getPriorityLetter()); + case INDEX_LOGTIME: + return m.getTime(); + case INDEX_PID: + return m.getPid(); + case INDEX_APPNAME: + return m.getAppName(); + case INDEX_TAG: + return m.getTag(); + case INDEX_TEXT: + return m.getMessage(); + default: + return ""; + } + } + + @Override + public void update(ViewerCell cell) { + Object element = cell.getElement(); + if (!(element instanceof LogCatMessage)) { + return; + } + LogCatMessage m = (LogCatMessage) element; + + String text = getCellText(m, cell.getColumnIndex()); + cell.setText(text); + cell.setFont(mLogFont); + cell.setForeground(getForegroundColor(m)); + } + + private Color getForegroundColor(LogCatMessage m) { + LogLevel l = m.getLogLevel(); + + if (l.equals(LogLevel.VERBOSE)) { + return VERBOSE_MSG_COLOR; + } else if (l.equals(LogLevel.INFO)) { + return INFO_MSG_COLOR; + } else if (l.equals(LogLevel.DEBUG)) { + return DEBUG_MSG_COLOR; + } else if (l.equals(LogLevel.ERROR)) { + return ERROR_MSG_COLOR; + } else if (l.equals(LogLevel.WARN)) { + return WARN_MSG_COLOR; + } + + return null; + } + + public void setFont(Font preferredFont) { + if (mLogFont != null) { + mLogFont.dispose(); + } + + mLogFont = preferredFont; + } + + public void setMinimumLengthForToolTips(int widthInChars) { + mWrapWidth = widthInChars; + } + + /** + * Obtain the tool tip to show for a particular logcat message. + * We display a tool tip only for messages longer than the width set by + * {@link #setMinimumLengthForToolTips(int)}. + */ + @Override + public String getToolTipText(Object element) { + String text = element.toString(); + if (text.length() > mWrapWidth) { + return text; + } else { + return null; + } + } + + @Override + public Point getToolTipShift(Object object) { + // The only reason we override this method is that the default shift amounts + // don't seem to work on OS X Lion. + return LOGCAT_TOOLTIP_SHIFT; + } +} diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/logcat/LogCatMessageList.java b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/logcat/LogCatMessageList.java new file mode 100644 index 00000000..0d0e3c24 --- /dev/null +++ b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/logcat/LogCatMessageList.java @@ -0,0 +1,115 @@ +/* + * Copyright (C) 2011 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.ddmuilib.logcat; + +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.BlockingQueue; + +/** + * Container for a list of log messages. The list of messages are + * maintained in a circular buffer (FIFO). + */ +public final class LogCatMessageList { + /** Preference key for size of the FIFO. */ + public static final String MAX_MESSAGES_PREFKEY = + "logcat.messagelist.max.size"; + + /** Default value for max # of messages. */ + public static final int MAX_MESSAGES_DEFAULT = 5000; + + private int mFifoSize; + private BlockingQueue mQ; + private LogCatMessage[] mQArray; + + /** + * Construct an empty message list. + * @param maxMessages capacity of the circular buffer + */ + public LogCatMessageList(int maxMessages) { + mFifoSize = maxMessages; + + mQ = new ArrayBlockingQueue(mFifoSize); + mQArray = new LogCatMessage[mFifoSize]; + } + + /** + * Resize the message list. + * @param n new size for the list + */ + public synchronized void resize(int n) { + mFifoSize = n; + + if (mFifoSize > mQ.size()) { + /* if resizing to a bigger fifo, we can copy over all elements from the current mQ */ + mQ = new ArrayBlockingQueue(mFifoSize, true, mQ); + } else { + /* for a smaller fifo, copy over the last n entries */ + LogCatMessage[] curMessages = mQ.toArray(new LogCatMessage[mQ.size()]); + mQ = new ArrayBlockingQueue(mFifoSize); + for (int i = curMessages.length - mFifoSize; i < curMessages.length; i++) { + mQ.offer(curMessages[i]); + } + } + + mQArray = new LogCatMessage[mFifoSize]; + } + + /** + * Append a message to the list. If the list is full, the first + * message will be popped off of it. + * @param m log to be inserted + */ + public synchronized void appendMessage(final LogCatMessage m) { + if (mQ.remainingCapacity() == 0) { + /* make space by removing the first entry */ + mQ.poll(); + } + mQ.offer(m); + } + + /** + * Returns the number of additional elements that this queue can + * ideally (in the absence of memory or resource constraints) + * accept without blocking. + * @return the remaining capacity + */ + public synchronized int remainingCapacity() { + return mQ.remainingCapacity(); + } + + /** + * Clear all messages in the list. + */ + public synchronized void clear() { + mQ.clear(); + } + + /** + * Obtain all the messages currently present in the list. + * @return array containing all the log messages + */ + public Object[] toArray() { + if (mQ.size() == mFifoSize) { + /* + * Once the queue is full, it stays full until the user explicitly clears + * all the logs. Optimize for this case by not reallocating the array. + */ + return mQ.toArray(mQArray); + } + return mQ.toArray(); + } +} diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/logcat/LogCatMessageParser.java b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/logcat/LogCatMessageParser.java new file mode 100644 index 00000000..b69a433d --- /dev/null +++ b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/logcat/LogCatMessageParser.java @@ -0,0 +1,94 @@ +/* + * Copyright (C) 2011 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.ddmuilib.logcat; + +import com.android.ddmlib.Log.LogLevel; + +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Class to parse raw output of {@code adb logcat -v long} to {@link LogCatMessage} objects. + */ +public final class LogCatMessageParser { + private LogLevel mCurLogLevel = LogLevel.WARN; + private String mCurPid = "?"; + private String mCurTid = "?"; + private String mCurTag = "?"; + private String mCurTime = "?:??"; + + /** + * This pattern is meant to parse the first line of a log message with the option + * 'logcat -v long'. The first line represents the date, tag, severity, etc.. while the + * following lines are the message (can be several lines).
+ * This first line looks something like:
+ * {@code "[ 00-00 00:00:00.000 :0x /]"} + *
+ * Note: severity is one of V, D, I, W, E, A? or F. However, there doesn't seem to be + * a way to actually generate an A (assert) message. Log.wtf is supposed to generate + * a message with severity A, however it generates the undocumented F level. In + * such a case, the parser will change the level from F to A.
+ * Note: the fraction of second value can have any number of digit.
+ * Note: the tag should be trimmed as it may have spaces at the end. + */ + private static Pattern sLogHeaderPattern = Pattern.compile( + "^\\[\\s(\\d\\d-\\d\\d\\s\\d\\d:\\d\\d:\\d\\d\\.\\d+)" + + "\\s+(\\d*):\\s*(\\S+)\\s([VDIWEAF])/(.*)\\]$"); + + /** + * Parse a list of strings into {@link LogCatMessage} objects. This method + * maintains state from previous calls regarding the last seen header of + * logcat messages. + * @param lines list of raw strings obtained from logcat -v long + * @param pidToNameMapper mapper to obtain the app name given a pid + * @return list of LogMessage objects parsed from the input + */ + public List processLogLines(String[] lines, + LogCatPidToNameMapper pidToNameMapper) { + List messages = new ArrayList(lines.length); + + for (String line : lines) { + if (line.length() == 0) { + continue; + } + + Matcher matcher = sLogHeaderPattern.matcher(line); + if (matcher.matches()) { + mCurTime = matcher.group(1); + mCurPid = matcher.group(2); + mCurTid = matcher.group(3); + mCurLogLevel = LogLevel.getByLetterString(matcher.group(4)); + mCurTag = matcher.group(5).trim(); + + /* LogLevel doesn't support messages with severity "F". Log.wtf() is supposed + * to generate "A", but generates "F". */ + if (mCurLogLevel == null && matcher.group(4).equals("F")) { + mCurLogLevel = LogLevel.ASSERT; + } + } else { + LogCatMessage m = new LogCatMessage(mCurLogLevel, mCurPid, mCurTid, + pidToNameMapper.getName(mCurPid), + mCurTag, mCurTime, line); + messages.add(m); + } + } + + return messages; + } +} diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/logcat/LogCatPanel.java b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/logcat/LogCatPanel.java new file mode 100644 index 00000000..7aa0328d --- /dev/null +++ b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/logcat/LogCatPanel.java @@ -0,0 +1,1203 @@ +/* + * Copyright (C) 2011 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.ddmuilib.logcat; + +import com.android.ddmlib.DdmConstants; +import com.android.ddmlib.IDevice; +import com.android.ddmlib.Log.LogLevel; +import com.android.ddmuilib.ITableFocusListener; +import com.android.ddmuilib.ITableFocusListener.IFocusedTableActivator; +import com.android.ddmuilib.ImageLoader; +import com.android.ddmuilib.SelectionDependentPanel; +import com.android.ddmuilib.TableHelper; + +import org.eclipse.jface.dialogs.MessageDialog; +import org.eclipse.jface.preference.IPreferenceStore; +import org.eclipse.jface.preference.PreferenceConverter; +import org.eclipse.jface.util.IPropertyChangeListener; +import org.eclipse.jface.util.PropertyChangeEvent; +import org.eclipse.jface.viewers.ColumnViewer; +import org.eclipse.jface.viewers.ColumnViewerToolTipSupport; +import org.eclipse.jface.viewers.TableViewer; +import org.eclipse.jface.viewers.TableViewerColumn; +import org.eclipse.jface.viewers.ViewerCell; +import org.eclipse.jface.window.ToolTip; +import org.eclipse.jface.window.Window; +import org.eclipse.swt.SWT; +import org.eclipse.swt.custom.SashForm; +import org.eclipse.swt.dnd.Clipboard; +import org.eclipse.swt.dnd.TextTransfer; +import org.eclipse.swt.dnd.Transfer; +import org.eclipse.swt.events.ControlAdapter; +import org.eclipse.swt.events.ControlEvent; +import org.eclipse.swt.events.FocusEvent; +import org.eclipse.swt.events.FocusListener; +import org.eclipse.swt.events.ModifyEvent; +import org.eclipse.swt.events.ModifyListener; +import org.eclipse.swt.events.SelectionAdapter; +import org.eclipse.swt.events.SelectionEvent; +import org.eclipse.swt.graphics.Font; +import org.eclipse.swt.graphics.FontData; +import org.eclipse.swt.graphics.GC; +import org.eclipse.swt.layout.GridData; +import org.eclipse.swt.layout.GridLayout; +import org.eclipse.swt.widgets.Combo; +import org.eclipse.swt.widgets.Composite; +import org.eclipse.swt.widgets.Control; +import org.eclipse.swt.widgets.Display; +import org.eclipse.swt.widgets.Event; +import org.eclipse.swt.widgets.FileDialog; +import org.eclipse.swt.widgets.Label; +import org.eclipse.swt.widgets.Listener; +import org.eclipse.swt.widgets.ScrollBar; +import org.eclipse.swt.widgets.Table; +import org.eclipse.swt.widgets.TableColumn; +import org.eclipse.swt.widgets.TableItem; +import org.eclipse.swt.widgets.Text; +import org.eclipse.swt.widgets.ToolBar; +import org.eclipse.swt.widgets.ToolItem; + +import java.io.BufferedWriter; +import java.io.FileWriter; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +/** + * LogCatPanel displays a table listing the logcat messages. + */ +public final class LogCatPanel extends SelectionDependentPanel + implements ILogCatMessageEventListener { + /** Preference key to use for storing list of logcat filters. */ + public static final String LOGCAT_FILTERS_LIST = "logcat.view.filters.list"; + + /** Preference key to use for storing font settings. */ + public static final String LOGCAT_VIEW_FONT_PREFKEY = "logcat.view.font"; + + // Use a monospace font family + private static final String FONT_FAMILY = + DdmConstants.CURRENT_PLATFORM == DdmConstants.PLATFORM_DARWIN ? "Monaco":"Courier New"; + + // Use the default system font size + private static final FontData DEFAULT_LOGCAT_FONTDATA; + static { + int h = Display.getDefault().getSystemFont().getFontData()[0].getHeight(); + DEFAULT_LOGCAT_FONTDATA = new FontData(FONT_FAMILY, h, SWT.NORMAL); + } + + private static final String LOGCAT_VIEW_COLSIZE_PREFKEY_PREFIX = "logcat.view.colsize."; + private static final String DISPLAY_FILTERS_COLUMN_PREFKEY = "logcat.view.display.filters"; + + /** Default message to show in the message search field. */ + private static final String DEFAULT_SEARCH_MESSAGE = + "Search for messages. Accepts Java regexes. " + + "Prefix with pid:, app:, tag: or text: to limit scope."; + + /** Tooltip to show in the message search field. */ + private static final String DEFAULT_SEARCH_TOOLTIP = + "Example search patterns:\n" + + " sqlite (search for sqlite in text field)\n" + + " app:browser (search for messages generated by the browser application)"; + + private static final String IMAGE_ADD_FILTER = "add.png"; //$NON-NLS-1$ + private static final String IMAGE_DELETE_FILTER = "delete.png"; //$NON-NLS-1$ + private static final String IMAGE_EDIT_FILTER = "edit.png"; //$NON-NLS-1$ + private static final String IMAGE_SAVE_LOG_TO_FILE = "save.png"; //$NON-NLS-1$ + private static final String IMAGE_CLEAR_LOG = "clear.png"; //$NON-NLS-1$ + private static final String IMAGE_DISPLAY_FILTERS = "displayfilters.png"; //$NON-NLS-1$ + private static final String IMAGE_PAUSE_LOGCAT = "pause_logcat.png"; //$NON-NLS-1$ + + private static final int[] WEIGHTS_SHOW_FILTERS = new int[] {15, 85}; + private static final int[] WEIGHTS_LOGCAT_ONLY = new int[] {0, 100}; + + private LogCatReceiver mReceiver; + private IPreferenceStore mPrefStore; + + private List mLogCatFilters; + private int mCurrentSelectedFilterIndex; + + private int mRemovedEntriesCount = 0; + private int mPreviousRemainingCapacity = 0; + + private ToolItem mNewFilterToolItem; + private ToolItem mDeleteFilterToolItem; + private ToolItem mEditFilterToolItem; + private TableViewer mFiltersTableViewer; + + private Combo mLiveFilterLevelCombo; + private Text mLiveFilterText; + + private TableViewer mViewer; + + private boolean mShouldScrollToLatestLog = true; + private ToolItem mPauseLogcatCheckBox; + private boolean mLastItemPainted = false; + + private String mLogFileExportFolder; + private LogCatMessageLabelProvider mLogCatMessageLabelProvider; + + private SashForm mSash; + + /** + * Construct a logcat panel. + * @param prefStore preference store where UI preferences will be saved + */ + public LogCatPanel(IPreferenceStore prefStore) { + mPrefStore = prefStore; + + initializeFilters(); + + setupDefaultPreferences(); + initializePreferenceUpdateListeners(); + } + + private void initializeFilters() { + mLogCatFilters = new ArrayList(); + + /* add default filter matching all messages */ + String tag = ""; + String text = ""; + String pid = ""; + String app = ""; + mLogCatFilters.add(new LogCatFilter("All messages (no filters)", + tag, text, pid, app, LogLevel.VERBOSE)); + + /* restore saved filters from prefStore */ + List savedFilters = getSavedFilters(); + mLogCatFilters.addAll(savedFilters); + } + + private void setupDefaultPreferences() { + PreferenceConverter.setDefault(mPrefStore, LogCatPanel.LOGCAT_VIEW_FONT_PREFKEY, + DEFAULT_LOGCAT_FONTDATA); + mPrefStore.setDefault(LogCatMessageList.MAX_MESSAGES_PREFKEY, + LogCatMessageList.MAX_MESSAGES_DEFAULT); + mPrefStore.setDefault(DISPLAY_FILTERS_COLUMN_PREFKEY, true); + } + + private void initializePreferenceUpdateListeners() { + mPrefStore.addPropertyChangeListener(new IPropertyChangeListener() { + @Override + public void propertyChange(PropertyChangeEvent event) { + String changedProperty = event.getProperty(); + + if (changedProperty.equals(LogCatPanel.LOGCAT_VIEW_FONT_PREFKEY)) { + mLogCatMessageLabelProvider.setFont(getFontFromPrefStore()); + refreshLogCatTable(); + } else if (changedProperty.equals( + LogCatMessageList.MAX_MESSAGES_PREFKEY)) { + mReceiver.resizeFifo(mPrefStore.getInt( + LogCatMessageList.MAX_MESSAGES_PREFKEY)); + refreshLogCatTable(); + } + } + }); + } + + private void saveFilterPreferences() { + LogCatFilterSettingsSerializer serializer = new LogCatFilterSettingsSerializer(); + + /* save all filter settings except the first one which is the default */ + String e = serializer.encodeToPreferenceString( + mLogCatFilters.subList(1, mLogCatFilters.size())); + mPrefStore.setValue(LOGCAT_FILTERS_LIST, e); + } + + private List getSavedFilters() { + LogCatFilterSettingsSerializer serializer = new LogCatFilterSettingsSerializer(); + String e = mPrefStore.getString(LOGCAT_FILTERS_LIST); + return serializer.decodeFromPreferenceString(e); + } + + @Override + public void deviceSelected() { + IDevice device = getCurrentDevice(); + if (device == null) { + // If the device is not working properly, getCurrentDevice() could return null. + // In such a case, we don't launch logcat, nor switch the display. + return; + } + + if (mReceiver != null) { + // Don't need to listen to new logcat messages from previous device anymore. + mReceiver.removeMessageReceivedEventListener(this); + + // When switching between devices, existing filter match count should be reset. + for (LogCatFilter f : mLogCatFilters) { + f.resetUnreadCount(); + } + } + + mReceiver = LogCatReceiverFactory.INSTANCE.newReceiver(device, mPrefStore); + mReceiver.addMessageReceivedEventListener(this); + mViewer.setInput(mReceiver.getMessages()); + + // Always scroll to last line whenever the selected device changes. + // Run this in a separate async thread to give the table some time to update after the + // setInput above. + Display.getDefault().asyncExec(new Runnable() { + @Override + public void run() { + scrollToLatestLog(); + } + }); + } + + @Override + public void clientSelected() { + } + + @Override + protected void postCreation() { + } + + @Override + protected Control createControl(Composite parent) { + GridLayout layout = new GridLayout(1, false); + parent.setLayout(layout); + + createViews(parent); + setupDefaults(); + + return null; + } + + private void createViews(Composite parent) { + mSash = createSash(parent); + + createListOfFilters(mSash); + createLogTableView(mSash); + + boolean showFilters = mPrefStore.getBoolean(DISPLAY_FILTERS_COLUMN_PREFKEY); + updateFiltersColumn(showFilters); + } + + private SashForm createSash(Composite parent) { + SashForm sash = new SashForm(parent, SWT.HORIZONTAL); + sash.setLayoutData(new GridData(GridData.FILL_BOTH)); + return sash; + } + + private void createListOfFilters(SashForm sash) { + Composite c = new Composite(sash, SWT.BORDER); + GridLayout layout = new GridLayout(2, false); + c.setLayout(layout); + c.setLayoutData(new GridData(GridData.FILL_BOTH)); + + createFiltersToolbar(c); + createFiltersTable(c); + } + + private void createFiltersToolbar(Composite parent) { + Label l = new Label(parent, SWT.NONE); + l.setText("Saved Filters"); + GridData gd = new GridData(); + gd.horizontalAlignment = SWT.LEFT; + l.setLayoutData(gd); + + ToolBar t = new ToolBar(parent, SWT.FLAT); + gd = new GridData(); + gd.horizontalAlignment = SWT.RIGHT; + t.setLayoutData(gd); + + /* new filter */ + mNewFilterToolItem = new ToolItem(t, SWT.PUSH); + mNewFilterToolItem.setImage( + ImageLoader.getDdmUiLibLoader().loadImage(IMAGE_ADD_FILTER, t.getDisplay())); + mNewFilterToolItem.setToolTipText("Add a new logcat filter"); + mNewFilterToolItem.addSelectionListener(new SelectionAdapter() { + @Override + public void widgetSelected(SelectionEvent arg0) { + addNewFilter(); + } + }); + + /* delete filter */ + mDeleteFilterToolItem = new ToolItem(t, SWT.PUSH); + mDeleteFilterToolItem.setImage( + ImageLoader.getDdmUiLibLoader().loadImage(IMAGE_DELETE_FILTER, t.getDisplay())); + mDeleteFilterToolItem.setToolTipText("Delete selected logcat filter"); + mDeleteFilterToolItem.addSelectionListener(new SelectionAdapter() { + @Override + public void widgetSelected(SelectionEvent arg0) { + deleteSelectedFilter(); + } + }); + + /* edit filter */ + mEditFilterToolItem = new ToolItem(t, SWT.PUSH); + mEditFilterToolItem.setImage( + ImageLoader.getDdmUiLibLoader().loadImage(IMAGE_EDIT_FILTER, t.getDisplay())); + mEditFilterToolItem.setToolTipText("Edit selected logcat filter"); + mEditFilterToolItem.addSelectionListener(new SelectionAdapter() { + @Override + public void widgetSelected(SelectionEvent arg0) { + editSelectedFilter(); + } + }); + } + + private void addNewFilter() { + LogCatFilterSettingsDialog d = new LogCatFilterSettingsDialog( + Display.getCurrent().getActiveShell()); + if (d.open() != Window.OK) { + return; + } + + LogCatFilter f = new LogCatFilter(d.getFilterName().trim(), + d.getTag().trim(), + d.getText().trim(), + d.getPid().trim(), + d.getAppName().trim(), + LogLevel.getByString(d.getLogLevel())); + + mLogCatFilters.add(f); + mFiltersTableViewer.refresh(); + + /* select the newly added entry */ + int idx = mLogCatFilters.size() - 1; + mFiltersTableViewer.getTable().setSelection(idx); + + filterSelectionChanged(); + saveFilterPreferences(); + } + + private void deleteSelectedFilter() { + int selectedIndex = mFiltersTableViewer.getTable().getSelectionIndex(); + if (selectedIndex <= 0) { + /* return if no selected filter, or the default filter was selected (0th). */ + return; + } + + mLogCatFilters.remove(selectedIndex); + mFiltersTableViewer.refresh(); + mFiltersTableViewer.getTable().setSelection(selectedIndex - 1); + + filterSelectionChanged(); + saveFilterPreferences(); + } + + private void editSelectedFilter() { + int selectedIndex = mFiltersTableViewer.getTable().getSelectionIndex(); + if (selectedIndex < 0) { + return; + } + + LogCatFilter curFilter = mLogCatFilters.get(selectedIndex); + + LogCatFilterSettingsDialog dialog = new LogCatFilterSettingsDialog( + Display.getCurrent().getActiveShell()); + dialog.setDefaults(curFilter.getName(), curFilter.getTag(), curFilter.getText(), + curFilter.getPid(), curFilter.getAppName(), curFilter.getLogLevel()); + if (dialog.open() != Window.OK) { + return; + } + + LogCatFilter f = new LogCatFilter(dialog.getFilterName(), + dialog.getTag(), + dialog.getText(), + dialog.getPid(), + dialog.getAppName(), + LogLevel.getByString(dialog.getLogLevel())); + mLogCatFilters.set(selectedIndex, f); + mFiltersTableViewer.refresh(); + + mFiltersTableViewer.getTable().setSelection(selectedIndex); + filterSelectionChanged(); + saveFilterPreferences(); + } + + /** + * Select the transient filter for the specified application. If no such filter + * exists, then create one and then select that. This method should be called from + * the UI thread. + * @param appName application name to filter by + */ + public void selectTransientAppFilter(String appName) { + assert mViewer.getTable().getDisplay().getThread() == Thread.currentThread(); + + LogCatFilter f = findTransientAppFilter(appName); + if (f == null) { + f = createTransientAppFilter(appName); + mLogCatFilters.add(f); + } + + selectFilterAt(mLogCatFilters.indexOf(f)); + } + + private LogCatFilter findTransientAppFilter(String appName) { + for (LogCatFilter f : mLogCatFilters) { + if (f.isTransient() && f.getAppName().equals(appName)) { + return f; + } + } + return null; + } + + private LogCatFilter createTransientAppFilter(String appName) { + LogCatFilter f = new LogCatFilter(appName + " (Session Filter)", + "", + "", + "", + appName, + LogLevel.VERBOSE); + f.setTransient(); + return f; + } + + private void selectFilterAt(final int index) { + mFiltersTableViewer.refresh(); + mFiltersTableViewer.getTable().setSelection(index); + filterSelectionChanged(); + } + + private void createFiltersTable(Composite parent) { + final Table table = new Table(parent, SWT.FULL_SELECTION); + + GridData gd = new GridData(GridData.FILL_BOTH); + gd.horizontalSpan = 2; + table.setLayoutData(gd); + + mFiltersTableViewer = new TableViewer(table); + mFiltersTableViewer.setContentProvider(new LogCatFilterContentProvider()); + mFiltersTableViewer.setLabelProvider(new LogCatFilterLabelProvider()); + mFiltersTableViewer.setInput(mLogCatFilters); + + mFiltersTableViewer.getTable().addSelectionListener(new SelectionAdapter() { + @Override + public void widgetSelected(SelectionEvent event) { + filterSelectionChanged(); + } + + @Override + public void widgetDefaultSelected(SelectionEvent arg0) { + editSelectedFilter(); + } + }); + } + + private void createLogTableView(SashForm sash) { + Composite c = new Composite(sash, SWT.NONE); + c.setLayout(new GridLayout()); + c.setLayoutData(new GridData(GridData.FILL_BOTH)); + + createLiveFilters(c); + createLogcatViewTable(c); + } + + /** + * Create the search bar at the top of the logcat messages table. + * FIXME: Currently, this feature is incomplete: The UI elements are created, but they + * are all set to disabled state. + */ + private void createLiveFilters(Composite parent) { + Composite c = new Composite(parent, SWT.NONE); + c.setLayout(new GridLayout(3, false)); + c.setLayoutData(new GridData(GridData.FILL_HORIZONTAL)); + + mLiveFilterText = new Text(c, SWT.BORDER | SWT.SEARCH); + mLiveFilterText.setLayoutData(new GridData(GridData.FILL_HORIZONTAL)); + mLiveFilterText.setMessage(DEFAULT_SEARCH_MESSAGE); + mLiveFilterText.setToolTipText(DEFAULT_SEARCH_TOOLTIP); + mLiveFilterText.addModifyListener(new ModifyListener() { + @Override + public void modifyText(ModifyEvent arg0) { + updateAppliedFilters(); + } + }); + + mLiveFilterLevelCombo = new Combo(c, SWT.READ_ONLY | SWT.DROP_DOWN); + mLiveFilterLevelCombo.setItems( + LogCatFilterSettingsDialog.getLogLevels().toArray(new String[0])); + mLiveFilterLevelCombo.select(0); + mLiveFilterLevelCombo.addSelectionListener(new SelectionAdapter() { + @Override + public void widgetSelected(SelectionEvent arg0) { + updateAppliedFilters(); + } + }); + + ToolBar toolBar = new ToolBar(c, SWT.FLAT); + + ToolItem saveToLog = new ToolItem(toolBar, SWT.PUSH); + saveToLog.setImage(ImageLoader.getDdmUiLibLoader().loadImage(IMAGE_SAVE_LOG_TO_FILE, + toolBar.getDisplay())); + saveToLog.setToolTipText("Export Selected Items To Text File.."); + saveToLog.addSelectionListener(new SelectionAdapter() { + @Override + public void widgetSelected(SelectionEvent arg0) { + saveLogToFile(); + } + }); + + ToolItem clearLog = new ToolItem(toolBar, SWT.PUSH); + clearLog.setImage( + ImageLoader.getDdmUiLibLoader().loadImage(IMAGE_CLEAR_LOG, toolBar.getDisplay())); + clearLog.setToolTipText("Clear Log"); + clearLog.addSelectionListener(new SelectionAdapter() { + @Override + public void widgetSelected(SelectionEvent arg0) { + if (mReceiver != null) { + mReceiver.clearMessages(); + refreshLogCatTable(); + + mRemovedEntriesCount = 0; + + // the filters view is not cleared unless the filters are re-applied. + updateAppliedFilters(); + } + } + }); + + final ToolItem showFiltersColumn = new ToolItem(toolBar, SWT.CHECK); + showFiltersColumn.setImage( + ImageLoader.getDdmUiLibLoader().loadImage(IMAGE_DISPLAY_FILTERS, + toolBar.getDisplay())); + showFiltersColumn.setSelection(mPrefStore.getBoolean(DISPLAY_FILTERS_COLUMN_PREFKEY)); + showFiltersColumn.setToolTipText("Display Saved Filters View"); + showFiltersColumn.addSelectionListener(new SelectionAdapter() { + @Override + public void widgetSelected(SelectionEvent event) { + boolean showFilters = showFiltersColumn.getSelection(); + mPrefStore.setValue(DISPLAY_FILTERS_COLUMN_PREFKEY, showFilters); + updateFiltersColumn(showFilters); + } + }); + + mPauseLogcatCheckBox = new ToolItem(toolBar, SWT.CHECK); + mPauseLogcatCheckBox.setImage( + ImageLoader.getDdmUiLibLoader().loadImage(IMAGE_PAUSE_LOGCAT, + toolBar.getDisplay())); + mPauseLogcatCheckBox.setSelection(false); + mPauseLogcatCheckBox.setToolTipText("Pause receiving new logcat messages."); + mPauseLogcatCheckBox.addSelectionListener(new SelectionAdapter() { + @Override + public void widgetSelected(SelectionEvent event) { + boolean pauseLogcat = mPauseLogcatCheckBox.getSelection(); + setScrollToLatestLog(!pauseLogcat, false); + } + }); + } + + private void updateFiltersColumn(boolean showFilters) { + if (showFilters) { + mSash.setWeights(WEIGHTS_SHOW_FILTERS); + } else { + mSash.setWeights(WEIGHTS_LOGCAT_ONLY); + } + } + + /** + * Save logcat messages selected in the table to a file. + */ + private void saveLogToFile() { + /* show dialog box and get target file name */ + final String fName = getLogFileTargetLocation(); + if (fName == null) { + return; + } + + /* obtain list of selected messages */ + final List selectedMessages = getSelectedLogCatMessages(); + + /* save messages to file in a different (non UI) thread */ + Thread t = new Thread(new Runnable() { + @Override + public void run() { + try { + BufferedWriter w = new BufferedWriter(new FileWriter(fName)); + for (LogCatMessage m : selectedMessages) { + w.append(m.toString()); + w.newLine(); + } + w.close(); + } catch (final IOException e) { + Display.getDefault().asyncExec(new Runnable() { + @Override + public void run() { + MessageDialog.openError(Display.getCurrent().getActiveShell(), + "Unable to export selection to file.", + "Unexpected error while saving selected messages to file: " + + e.getMessage()); + } + }); + } + } + }); + t.setName("Saving selected items to logfile.."); + t.start(); + } + + /** + * Display a {@link FileDialog} to the user and obtain the location for the log file. + * @return path to target file, null if user canceled the dialog + */ + private String getLogFileTargetLocation() { + FileDialog fd = new FileDialog(Display.getCurrent().getActiveShell(), SWT.SAVE); + + fd.setText("Save Log.."); + fd.setFileName("log.txt"); + + if (mLogFileExportFolder == null) { + mLogFileExportFolder = System.getProperty("user.home"); + } + fd.setFilterPath(mLogFileExportFolder); + + fd.setFilterNames(new String[] { + "Text Files (*.txt)" + }); + fd.setFilterExtensions(new String[] { + "*.txt" + }); + + String fName = fd.open(); + if (fName != null) { + mLogFileExportFolder = fd.getFilterPath(); /* save path to restore on future calls */ + } + + return fName; + } + + private List getSelectedLogCatMessages() { + Table table = mViewer.getTable(); + int[] indices = table.getSelectionIndices(); + Arrays.sort(indices); /* Table.getSelectionIndices() does not specify an order */ + + // Get items from the table's input as opposed to getting each table item's data. + // Retrieving table item's data can return NULL in case of a virtual table if the item + // has not been displayed yet. + Object input = mViewer.getInput(); + if (!(input instanceof LogCatMessageList)) { + return Collections.emptyList(); + } + + List filteredItems = applyCurrentFilters((LogCatMessageList) input); + List selectedMessages = new ArrayList(indices.length); + for (int i : indices) { + // consider removed logcat message entries + i -= mRemovedEntriesCount; + if (i >= 0 && i < filteredItems.size()) { + LogCatMessage m = filteredItems.get(i); + selectedMessages.add(m); + } + } + + return selectedMessages; + } + + private List applyCurrentFilters(LogCatMessageList msgList) { + Object[] items = msgList.toArray(); + List filteredItems = new ArrayList(items.length); + List filters = getFiltersToApply(); + + for (Object item : items) { + if (!(item instanceof LogCatMessage)) { + continue; + } + + LogCatMessage msg = (LogCatMessage) item; + if (!isMessageFiltered(msg, filters)) { + filteredItems.add(msg); + } + } + + return filteredItems; + } + + private boolean isMessageFiltered(LogCatMessage msg, List filters) { + for (LogCatViewerFilter f : filters) { + if (!f.select(null, null, msg)) { + // message does not make it through this filter + return true; + } + } + + return false; + } + + private void createLogcatViewTable(Composite parent) { + // The SWT.VIRTUAL bit causes the table to be rendered faster. However it makes all rows + // to be of the same height, thereby clipping any rows with multiple lines of text. + // In such a case, users can view the full text by hovering over the item and looking at + // the tooltip. + final Table table = new Table(parent, SWT.FULL_SELECTION | SWT.MULTI | SWT.VIRTUAL); + mViewer = new TableViewer(table); + + table.setLayoutData(new GridData(GridData.FILL_BOTH)); + table.getHorizontalBar().setVisible(true); + + /** Columns to show in the table. */ + String[] properties = { + "Level", + "Time", + "PID", + "Application", + "Tag", + "Text", + }; + + /** The sampleText for each column is used to determine the default widths + * for each column. The contents do not matter, only their lengths are needed. */ + String[] sampleText = { + " ", + " 00-00 00:00:00.0000 ", + " 0000", + " com.android.launcher", + " SampleTagText", + " Log Message field should be pretty long by default. As long as possible for correct display on Mac.", + }; + + mLogCatMessageLabelProvider = new LogCatMessageLabelProvider(getFontFromPrefStore()); + for (int i = 0; i < properties.length; i++) { + TableColumn tc = TableHelper.createTableColumn(mViewer.getTable(), + properties[i], /* Column title */ + SWT.LEFT, /* Column Style */ + sampleText[i], /* String to compute default col width */ + getColPreferenceKey(properties[i]), /* Preference Store key for this column */ + mPrefStore); + TableViewerColumn tvc = new TableViewerColumn(mViewer, tc); + tvc.setLabelProvider(mLogCatMessageLabelProvider); + } + + mViewer.getTable().setLinesVisible(true); /* zebra stripe the table */ + mViewer.getTable().setHeaderVisible(true); + mViewer.setContentProvider(new LogCatMessageContentProvider()); + WrappingToolTipSupport.enableFor(mViewer, ToolTip.NO_RECREATE); + + // Set the row height to be sufficient enough to display the current font. + // This is not strictly necessary, except that on WinXP, the rows showed up clipped. So + // we explicitly set it to be sure. + mViewer.getTable().addListener(SWT.MeasureItem, new Listener() { + @Override + public void handleEvent(Event event) { + event.height = event.gc.getFontMetrics().getHeight(); + } + }); + + // Update the label provider whenever the text column's width changes + TableColumn textColumn = mViewer.getTable().getColumn(properties.length - 1); + textColumn.addControlListener(new ControlAdapter() { + @Override + public void controlResized(ControlEvent event) { + TableColumn tc = (TableColumn) event.getSource(); + int width = tc.getWidth(); + GC gc = new GC(tc.getParent()); + int avgCharWidth = gc.getFontMetrics().getAverageCharWidth(); + gc.dispose(); + + if (mLogCatMessageLabelProvider != null) { + mLogCatMessageLabelProvider.setMinimumLengthForToolTips(width/avgCharWidth); + } + } + }); + + setupAutoScrollLockBehavior(); + initDoubleClickListener(); + } + + /** + * Setup to automatically enable or disable scroll lock. From a user's perspective, + * the logcat window will:

    + *
  • Automatically scroll and reveal new entries if the scrollbar is at the bottom.
  • + *
  • Not scroll even when new messages are received if the scrollbar is not at the bottom. + *
  • + *
+ * This requires that we are able to detect where the scrollbar is and what direction + * it is moving. Unfortunately, that proves to be very platform dependent. Here's the behavior + * of the scroll events on different platforms:
    + *
  • On Windows, scroll bar events specify which direction the scrollbar is moving, but + * it is not possible to determine if the scrollbar is right at the end.
  • + *
  • On Mac/Cocoa, scroll bar events do not specify the direction of movement (it is always + * set to SWT.DRAG), and it is not possible to identify where the scrollbar is since + * small movements of the scrollbar are not reflected in sb.getSelection().
  • + *
  • On Linux/gtk, we don't get the direction, but we can accurately locate the + * scrollbar location using getSelection(), getThumb() and getMaximum(). + *
+ */ + private void setupAutoScrollLockBehavior() { + if (DdmConstants.CURRENT_PLATFORM == DdmConstants.PLATFORM_WINDOWS) { + // On Windows, it is not possible to detect whether the scrollbar is at the + // bottom using the values of ScrollBar.getThumb, getSelection and getMaximum. + // Instead we resort to the following workaround: attach to the paint listener + // and see if the last item has been painted since the previous scroll event. + // If the last item has been painted, then we assume that we are at the bottom. + mViewer.getTable().addListener(SWT.PaintItem, new Listener() { + @Override + public void handleEvent(Event event) { + TableItem item = (TableItem) event.item; + TableItem[] items = mViewer.getTable().getItems(); + if (items.length > 0 && items[items.length - 1] == item) { + mLastItemPainted = true; + } + } + }); + mViewer.getTable().getVerticalBar().addSelectionListener(new SelectionAdapter() { + @Override + public void widgetSelected(SelectionEvent event) { + boolean scrollToLast; + if (event.detail == SWT.ARROW_UP || event.detail == SWT.PAGE_UP + || event.detail == SWT.HOME) { + // if we know that we are moving up, then do not scroll down + scrollToLast = false; + } else { + // otherwise, enable scrollToLast only if the last item was displayed + scrollToLast = mLastItemPainted; + } + + setScrollToLatestLog(scrollToLast, true); + mLastItemPainted = false; + } + }); + } else if (DdmConstants.CURRENT_PLATFORM == DdmConstants.PLATFORM_LINUX) { + // On Linux/gtk, we do not get any details regarding the scroll event (up/down/etc). + // So we completely rely on whether the scrollbar is at the bottom or not. + mViewer.getTable().getVerticalBar().addSelectionListener(new SelectionAdapter() { + @Override + public void widgetSelected(SelectionEvent event) { + ScrollBar sb = (ScrollBar) event.getSource(); + boolean scrollToLast = sb.getSelection() + sb.getThumb() == sb.getMaximum(); + setScrollToLatestLog(scrollToLast, true); + } + }); + } else { + // On Mac, we do not get any details regarding the (trackball) scroll event, + // nor can we rely on getSelection() changing for small movements. As a result, we + // do not setup any auto scroll lock behavior. Mac users have to manually pause and + // unpause if they are looking at a particular item in a high volume stream of events. + } + } + + private void setScrollToLatestLog(boolean scroll, boolean updateCheckbox) { + mShouldScrollToLatestLog = scroll; + + if (updateCheckbox) { + mPauseLogcatCheckBox.setSelection(!scroll); + } + + if (scroll) { + mViewer.refresh(); + scrollToLatestLog(); + } + } + + private static class WrappingToolTipSupport extends ColumnViewerToolTipSupport { + protected WrappingToolTipSupport(ColumnViewer viewer, int style, + boolean manualActivation) { + super(viewer, style, manualActivation); + } + + @Override + protected Composite createViewerToolTipContentArea(Event event, ViewerCell cell, + Composite parent) { + Composite comp = new Composite(parent, SWT.NONE); + GridLayout l = new GridLayout(1, false); + l.horizontalSpacing = 0; + l.marginWidth = 0; + l.marginHeight = 0; + l.verticalSpacing = 0; + comp.setLayout(l); + + Text text = new Text(comp, SWT.BORDER | SWT.V_SCROLL | SWT.WRAP); + text.setEditable(false); + text.setText(cell.getElement().toString()); + text.setLayoutData(new GridData(500, 150)); + + return comp; + } + + @Override + public boolean isHideOnMouseDown() { + return false; + } + + public static final void enableFor(ColumnViewer viewer, int style) { + new WrappingToolTipSupport(viewer, style, false); + } + } + + private String getColPreferenceKey(String field) { + return LOGCAT_VIEW_COLSIZE_PREFKEY_PREFIX + field; + } + + private Font getFontFromPrefStore() { + FontData fd = PreferenceConverter.getFontData(mPrefStore, + LogCatPanel.LOGCAT_VIEW_FONT_PREFKEY); + return new Font(Display.getDefault(), fd); + } + + private void setupDefaults() { + int defaultFilterIndex = 0; + mFiltersTableViewer.getTable().setSelection(defaultFilterIndex); + + filterSelectionChanged(); + } + + /** + * Perform all necessary updates whenever a filter is selected (by user or programmatically). + */ + private void filterSelectionChanged() { + int idx = getSelectedSavedFilterIndex(); + if (idx == -1) { + /* One of the filters should always be selected. + * On Linux, there is no way to deselect an item. + * On Mac, clicking inside the list view, but not an any item will result + * in all items being deselected. In such a case, we simply reselect the + * first entry. */ + idx = 0; + mFiltersTableViewer.getTable().setSelection(idx); + } + + mCurrentSelectedFilterIndex = idx; + + resetUnreadCountForSelectedFilter(); + updateFiltersToolBar(); + updateAppliedFilters(); + } + + private void resetUnreadCountForSelectedFilter() { + int index = getSelectedSavedFilterIndex(); + mLogCatFilters.get(index).resetUnreadCount(); + + refreshFiltersTable(); + } + + private int getSelectedSavedFilterIndex() { + return mFiltersTableViewer.getTable().getSelectionIndex(); + } + + private void updateFiltersToolBar() { + /* The default filter at index 0 can neither be edited, nor removed. */ + boolean en = getSelectedSavedFilterIndex() != 0; + mEditFilterToolItem.setEnabled(en); + mDeleteFilterToolItem.setEnabled(en); + } + + private void updateAppliedFilters() { + List filters = getFiltersToApply(); + mViewer.setFilters(filters.toArray(new LogCatViewerFilter[filters.size()])); + + /* whenever filters are changed, the number of displayed logs changes + * drastically. Display the latest log in such a situation. */ + scrollToLatestLog(); + } + + private List getFiltersToApply() { + /* list of filters to apply = saved filter + live filters */ + List filters = new ArrayList(); + filters.add(getSelectedSavedFilter()); + filters.addAll(getCurrentLiveFilters()); + return filters; + } + + private List getCurrentLiveFilters() { + List liveFilters = new ArrayList(); + + List liveFilterSettings = LogCatFilter.fromString( + mLiveFilterText.getText(), /* current query */ + LogLevel.getByString(mLiveFilterLevelCombo.getText())); /* current log level */ + for (LogCatFilter s : liveFilterSettings) { + liveFilters.add(new LogCatViewerFilter(s)); + } + + return liveFilters; + } + + private LogCatViewerFilter getSelectedSavedFilter() { + int index = getSelectedSavedFilterIndex(); + return new LogCatViewerFilter(mLogCatFilters.get(index)); + } + + + @Override + public void setFocus() { + } + + /** + * Update view whenever a message is received. + * @param receivedMessages list of messages from logcat + * Implements {@link ILogCatMessageEventListener#messageReceived()}. + */ + @Override + public void messageReceived(List receivedMessages) { + refreshLogCatTable(); + + if (mShouldScrollToLatestLog) { + updateUnreadCount(receivedMessages); + refreshFiltersTable(); + } else { + LogCatMessageList messageList = mReceiver.getMessages(); + int remainingCapacity = messageList.remainingCapacity(); + if (remainingCapacity == 0) { + mRemovedEntriesCount += + receivedMessages.size() - mPreviousRemainingCapacity; + } + mPreviousRemainingCapacity = remainingCapacity; + } + } + + /** + * When new messages are received, and they match a saved filter, update + * the unread count associated with that filter. + * @param receivedMessages list of new messages received + */ + private void updateUnreadCount(List receivedMessages) { + for (int i = 0; i < mLogCatFilters.size(); i++) { + if (i == mCurrentSelectedFilterIndex) { + /* no need to update unread count for currently selected filter */ + continue; + } + mLogCatFilters.get(i).updateUnreadCount(receivedMessages); + } + } + + private void refreshFiltersTable() { + Display.getDefault().asyncExec(new Runnable() { + @Override + public void run() { + if (mFiltersTableViewer.getTable().isDisposed()) { + return; + } + mFiltersTableViewer.refresh(); + } + }); + } + + /** Task currently submitted to {@link Display#asyncExec} to be run in UI thread. */ + private LogCatTableRefresherTask mCurrentRefresher; + + /** + * Refresh the logcat table asynchronously from the UI thread. + * This method adds a new async refresh only if there are no pending refreshes for the table. + * Doing so eliminates redundant refresh threads from being queued up to be run on the + * display thread. + */ + private void refreshLogCatTable() { + synchronized (this) { + if (mCurrentRefresher == null && mShouldScrollToLatestLog) { + mCurrentRefresher = new LogCatTableRefresherTask(); + Display.getDefault().asyncExec(mCurrentRefresher); + } + } + } + + private class LogCatTableRefresherTask implements Runnable { + @Override + public void run() { + if (mViewer.getTable().isDisposed()) { + return; + } + synchronized (LogCatPanel.this) { + mCurrentRefresher = null; + } + + if (mShouldScrollToLatestLog) { + mViewer.refresh(); + scrollToLatestLog(); + } + } + } + + /** Scroll to the last line. */ + private void scrollToLatestLog() { + mRemovedEntriesCount = 0; + mViewer.getTable().setTopIndex(mViewer.getTable().getItemCount() - 1); + } + + private List mMessageSelectionListeners; + + private void initDoubleClickListener() { + mMessageSelectionListeners = new ArrayList(1); + + mViewer.getTable().addSelectionListener(new SelectionAdapter() { + @Override + public void widgetDefaultSelected(SelectionEvent arg0) { + List selectedMessages = getSelectedLogCatMessages(); + if (selectedMessages.size() == 0) { + return; + } + + for (ILogCatMessageSelectionListener l : mMessageSelectionListeners) { + l.messageDoubleClicked(selectedMessages.get(0)); + } + } + }); + } + + public void addLogCatMessageSelectionListener(ILogCatMessageSelectionListener l) { + mMessageSelectionListeners.add(l); + } + + private ITableFocusListener mTableFocusListener; + + /** + * Specify the listener to be called when the logcat view gets focus. This interface is + * required by DDMS to hook up the menu items for Copy and Select All. + * @param listener listener to be notified when logcat view is in focus + */ + public void setTableFocusListener(ITableFocusListener listener) { + mTableFocusListener = listener; + + final Table table = mViewer.getTable(); + final IFocusedTableActivator activator = new IFocusedTableActivator() { + @Override + public void copy(Clipboard clipboard) { + copySelectionToClipboard(clipboard); + } + + @Override + public void selectAll() { + table.selectAll(); + } + }; + + table.addFocusListener(new FocusListener() { + @Override + public void focusGained(FocusEvent e) { + mTableFocusListener.focusGained(activator); + } + + @Override + public void focusLost(FocusEvent e) { + mTableFocusListener.focusLost(activator); + } + }); + } + + /** Copy all selected messages to clipboard. */ + public void copySelectionToClipboard(Clipboard clipboard) { + StringBuilder sb = new StringBuilder(); + + for (LogCatMessage m : getSelectedLogCatMessages()) { + sb.append(m.toString()); + sb.append('\n'); + } + + if (sb.length() > 0) { + clipboard.setContents( + new Object[] {sb.toString()}, + new Transfer[] {TextTransfer.getInstance()} + ); + } + } + + /** Select all items in the logcat table. */ + public void selectAll() { + mViewer.getTable().selectAll(); + } +} diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/logcat/LogCatPidToNameMapper.java b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/logcat/LogCatPidToNameMapper.java new file mode 100644 index 00000000..a4455d01 --- /dev/null +++ b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/logcat/LogCatPidToNameMapper.java @@ -0,0 +1,133 @@ +/* + * Copyright (C) 2011 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.ddmuilib.logcat; + +import com.android.ddmlib.AndroidDebugBridge; +import com.android.ddmlib.AndroidDebugBridge.IClientChangeListener; +import com.android.ddmlib.AndroidDebugBridge.IDeviceChangeListener; +import com.android.ddmlib.Client; +import com.android.ddmlib.ClientData; +import com.android.ddmlib.IDevice; + +import java.util.HashMap; +import java.util.Map; + +/** + * This class maintains a mapping between the PID and the application name for all + * running apps on a device. It does this by implementing callbacks to two events: + * {@link AndroidDebugBridge.IDeviceChangeListener} and + * {@link AndroidDebugBridge.IClientChangeListener}. + */ +public class LogCatPidToNameMapper { + /** Default name used when the actual name cannot be determined. */ + public static final String UNKNOWN_APP = ""; + + private IClientChangeListener mClientChangeListener; + private IDeviceChangeListener mDeviceChangeListener; + private IDevice mDevice; + private Map mPidToName; + + public LogCatPidToNameMapper(IDevice device) { + mDevice = device; + mClientChangeListener = constructClientChangeListener(); + AndroidDebugBridge.addClientChangeListener(mClientChangeListener); + + mDeviceChangeListener = constructDeviceChangeListener(); + AndroidDebugBridge.addDeviceChangeListener(mDeviceChangeListener); + + mPidToName = new HashMap(); + + updateClientList(device); + } + + private IClientChangeListener constructClientChangeListener() { + return new IClientChangeListener() { + @Override + public void clientChanged(Client client, int changeMask) { + if ((changeMask & Client.CHANGE_NAME) == Client.CHANGE_NAME) { + ClientData cd = client.getClientData(); + updateClientName(cd); + } + } + }; + } + + private void updateClientName(ClientData cd) { + String name = cd.getClientDescription(); + if (name != null) { + int pid = cd.getPid(); + if (mPidToName != null) { + mPidToName.put(Integer.toString(pid), name); + } + } + } + + private IDeviceChangeListener constructDeviceChangeListener() { + return new IDeviceChangeListener() { + @Override + public void deviceDisconnected(IDevice device) { + } + + @Override + public void deviceConnected(IDevice device) { + } + + @Override + public void deviceChanged(IDevice device, int changeMask) { + if (changeMask == IDevice.CHANGE_CLIENT_LIST) { + updateClientList(device); + } + } + }; + } + + private void updateClientList(IDevice device) { + if (mDevice == null) { + return; + } + + if (!mDevice.equals(device)) { + return; + } + + mPidToName = new HashMap(); + for (Client c : device.getClients()) { + ClientData cd = c.getClientData(); + String name = cd.getClientDescription(); + int pid = cd.getPid(); + + /* The name will be null for apps that have just been created. + * In such a case, we fill in the default name, and wait for the + * clientChangeListener to do the update with the correct name. + */ + if (name == null) { + name = UNKNOWN_APP; + } + + mPidToName.put(Integer.toString(pid), name); + } + } + + /** + * Get the application name corresponding to given pid. + * @param pid application's pid + * @return application name if available, else {@link LogCatPidToNameMapper#UNKNOWN_APP}. + */ + public String getName(String pid) { + String name = mPidToName.get(pid); + return name != null ? name : UNKNOWN_APP; + } +} diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/logcat/LogCatReceiver.java b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/logcat/LogCatReceiver.java new file mode 100644 index 00000000..c9606f62 --- /dev/null +++ b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/logcat/LogCatReceiver.java @@ -0,0 +1,201 @@ +/* + * Copyright (C) 2011 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.ddmuilib.logcat; + +import com.android.ddmlib.IDevice; +import com.android.ddmlib.IShellOutputReceiver; +import com.android.ddmlib.Log; +import com.android.ddmlib.MultiLineReceiver; + +import org.eclipse.jface.preference.IPreferenceStore; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** + * A class to monitor a device for logcat messages. It stores the received + * log messages in a circular buffer. + */ +public final class LogCatReceiver { + private static final String LOGCAT_COMMAND = "logcat -v long"; + private static final int DEVICE_POLL_INTERVAL_MSEC = 1000; + + private LogCatMessageList mLogMessages; + private IDevice mCurrentDevice; + private LogCatOutputReceiver mCurrentLogCatOutputReceiver; + private Set mLogCatMessageListeners; + private LogCatMessageParser mLogCatMessageParser; + private LogCatPidToNameMapper mPidToNameMapper; + private IPreferenceStore mPrefStore; + + /** + * Construct a LogCat message receiver for provided device. This will launch a + * logcat command on the device, and monitor the output of that command in + * a separate thread. All logcat messages are then stored in a circular + * buffer, which can be retrieved using {@link LogCatReceiver#getMessages()}. + * @param device device to monitor for logcat messages + * @param prefStore + */ + public LogCatReceiver(IDevice device, IPreferenceStore prefStore) { + mCurrentDevice = device; + mPrefStore = prefStore; + + mLogCatMessageListeners = new HashSet(); + mLogCatMessageParser = new LogCatMessageParser(); + mPidToNameMapper = new LogCatPidToNameMapper(mCurrentDevice); + + mLogMessages = new LogCatMessageList(getFifoSize()); + + startReceiverThread(); + } + + /** + * Stop receiving messages from currently active device. + */ + public void stop() { + if (mCurrentLogCatOutputReceiver != null) { + /* stop the current logcat command */ + mCurrentLogCatOutputReceiver.mIsCancelled = true; + mCurrentLogCatOutputReceiver = null; + } + + mLogMessages = null; + mCurrentDevice = null; + } + + private int getFifoSize() { + int n = mPrefStore.getInt(LogCatMessageList.MAX_MESSAGES_PREFKEY); + return n == 0 ? LogCatMessageList.MAX_MESSAGES_DEFAULT : n; + } + + private void startReceiverThread() { + mCurrentLogCatOutputReceiver = new LogCatOutputReceiver(); + + Thread t = new Thread(new Runnable() { + @Override + public void run() { + /* wait while the device comes online */ + while (!mCurrentDevice.isOnline()) { + try { + Thread.sleep(DEVICE_POLL_INTERVAL_MSEC); + } catch (InterruptedException e) { + return; + } + } + + try { + mCurrentDevice.executeShellCommand(LOGCAT_COMMAND, + mCurrentLogCatOutputReceiver, 0); + } catch (Exception e) { + /* There are 4 possible exceptions: TimeoutException, + * AdbCommandRejectedException, ShellCommandUnresponsiveException and + * IOException. In case of any of them, the only recourse is to just + * log this unexpected situation and move on. + */ + Log.e("Unexpected error while launching logcat. Try reselecting the device.", + e); + } + } + }); + t.setName("LogCat output receiver for " + mCurrentDevice.getSerialNumber()); + t.start(); + } + + /** + * LogCatOutputReceiver implements {@link MultiLineReceiver#processNewLines(String[])}, + * which is called whenever there is output from logcat. It simply redirects this output + * to {@link LogCatReceiver#processLogLines(String[])}. This class is expected to be + * used from a different thread, and the only way to stop that thread is by using the + * {@link LogCatOutputReceiver#mIsCancelled} variable. + * See {@link IDevice#executeShellCommand(String, IShellOutputReceiver, int)} for more + * details. + */ + private class LogCatOutputReceiver extends MultiLineReceiver { + private boolean mIsCancelled; + + public LogCatOutputReceiver() { + setTrimLine(false); + } + + /** Implements {@link IShellOutputReceiver#isCancelled() }. */ + @Override + public boolean isCancelled() { + return mIsCancelled; + } + + @Override + public void processNewLines(String[] lines) { + if (!mIsCancelled) { + processLogLines(lines); + } + } + } + + private void processLogLines(String[] lines) { + List messages = mLogCatMessageParser.processLogLines(lines, + mPidToNameMapper); + + if (messages.size() > 0) { + for (LogCatMessage m : messages) { + mLogMessages.appendMessage(m); + } + sendMessageReceivedEvent(messages); + } + } + + /** + * Get the list of logcat messages received from currently active device. + * @return list of messages if currently listening, null otherwise + */ + public LogCatMessageList getMessages() { + return mLogMessages; + } + + /** + * Clear the list of messages received from the currently active device. + */ + public void clearMessages() { + mLogMessages.clear(); + } + + /** + * Add to list of message event listeners. + * @param l listener to notified when messages are received from the device + */ + public void addMessageReceivedEventListener(ILogCatMessageEventListener l) { + mLogCatMessageListeners.add(l); + } + + public void removeMessageReceivedEventListener(ILogCatMessageEventListener l) { + mLogCatMessageListeners.remove(l); + } + + private void sendMessageReceivedEvent(List messages) { + for (ILogCatMessageEventListener l : mLogCatMessageListeners) { + l.messageReceived(messages); + } + } + + /** + * Resize the internal FIFO. + * @param size new size + */ + public void resizeFifo(int size) { + mLogMessages.resize(size); + } +} diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/logcat/LogCatReceiverFactory.java b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/logcat/LogCatReceiverFactory.java new file mode 100644 index 00000000..5b25e172 --- /dev/null +++ b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/logcat/LogCatReceiverFactory.java @@ -0,0 +1,95 @@ +/* + * Copyright (C) 2011 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.ddmuilib.logcat; + +import com.android.ddmlib.AndroidDebugBridge; +import com.android.ddmlib.AndroidDebugBridge.IDeviceChangeListener; +import com.android.ddmlib.IDevice; + +import org.eclipse.jface.preference.IPreferenceStore; + +import java.util.HashMap; +import java.util.Map; + +/** + * A factory for {@link LogCatReceiver} objects. Its primary objective is to cache + * constructed {@link LogCatReceiver}'s per device and hand them back when requested. + */ +public class LogCatReceiverFactory { + /** Singleton instance. */ + public static final LogCatReceiverFactory INSTANCE = new LogCatReceiverFactory(); + + private Map mReceiverCache = new HashMap(); + + /** Private constructor: cannot instantiate. */ + private LogCatReceiverFactory() { + AndroidDebugBridge.addDeviceChangeListener(new IDeviceChangeListener() { + @Override + public void deviceDisconnected(final IDevice device) { + // The deviceDisconnected() is called from DDMS code that holds + // multiple locks regarding list of clients, etc. + // It so happens that #newReceiver() below adds a clientChangeListener + // which requires those locks as well. So if we call + // #removeReceiverFor from a DDMS/Monitor thread, we could end up + // in a deadlock. As a result, we spawn a separate thread that + // doesn't hold any of the DDMS locks to remove the receiver. + Thread t = new Thread(new Runnable() { + @Override + public void run() { + removeReceiverFor(device); } + }, "Remove logcat receiver for " + device.getSerialNumber()); + t.start(); + } + + @Override + public void deviceConnected(IDevice device) { + } + + @Override + public void deviceChanged(IDevice device, int changeMask) { + } + }); + } + + /** + * Remove existing logcat receivers. This method should not be called from a DDMS thread + * context that might be holding locks. Doing so could result in a deadlock with the following + * two threads locked up:
    + *
  • {@link #removeReceiverFor(IDevice)} waiting to lock {@link LogCatReceiverFactory}, + * while holding a DDMS monitor internal lock.
  • + *
  • {@link #newReceiver(IDevice, IPreferenceStore)} holding {@link LogCatReceiverFactory} + * while attempting to obtain a DDMS monitor lock.
  • + *
+ */ + private synchronized void removeReceiverFor(IDevice device) { + LogCatReceiver r = mReceiverCache.get(device.getSerialNumber()); + if (r != null) { + r.stop(); + mReceiverCache.remove(device.getSerialNumber()); + } + } + + public synchronized LogCatReceiver newReceiver(IDevice device, IPreferenceStore prefs) { + LogCatReceiver r = mReceiverCache.get(device.getSerialNumber()); + if (r != null) { + return r; + } + + r = new LogCatReceiver(device, prefs); + mReceiverCache.put(device.getSerialNumber(), r); + return r; + } +} diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/logcat/LogCatStackTraceParser.java b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/logcat/LogCatStackTraceParser.java new file mode 100644 index 00000000..3da9fd0d --- /dev/null +++ b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/logcat/LogCatStackTraceParser.java @@ -0,0 +1,81 @@ +/* + * Copyright (C) 2011 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.ddmuilib.logcat; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Helper class that can determine if a string matches the exception + * stack trace pattern, and if so, can provide the java source file + * and line where the exception occured. + */ +public final class LogCatStackTraceParser { + /** Regex to match a stack trace line. E.g.: + * at com.foo.Class.method(FileName.extension:10) + * extension is typically java, but can be anything (java/groovy/scala/..). + */ + private static final String EXCEPTION_LINE_REGEX = + "\\s*at\\ (.*)\\((.*)\\..*\\:(\\d+)\\)"; //$NON-NLS-1$ + + private static final Pattern EXCEPTION_LINE_PATTERN = + Pattern.compile(EXCEPTION_LINE_REGEX); + + /** + * Identify if a input line matches the expected pattern + * for a stack trace from an exception. + */ + public boolean isValidExceptionTrace(String line) { + return EXCEPTION_LINE_PATTERN.matcher(line).find(); + } + + /** + * Get fully qualified method name that threw the exception. + * @param line line from the stack trace, must have been validated with + * {@link LogCatStackTraceParser#isValidExceptionTrace(String)} before calling this method. + * @return fully qualified method name + */ + public String getMethodName(String line) { + Matcher m = EXCEPTION_LINE_PATTERN.matcher(line); + m.find(); + return m.group(1); + } + + /** + * Get source file name where exception was generated. Input line must be first validated with + * {@link LogCatStackTraceParser#isValidExceptionTrace(String)}. + */ + public String getFileName(String line) { + Matcher m = EXCEPTION_LINE_PATTERN.matcher(line); + m.find(); + return m.group(2); + } + + /** + * Get line number where exception was generated. Input line must be first validated with + * {@link LogCatStackTraceParser#isValidExceptionTrace(String)}. + */ + public int getLineNumber(String line) { + Matcher m = EXCEPTION_LINE_PATTERN.matcher(line); + m.find(); + try { + return Integer.parseInt(m.group(3)); + } catch (NumberFormatException e) { + return 0; + } + } + +} diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/logcat/LogCatViewerFilter.java b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/logcat/LogCatViewerFilter.java new file mode 100644 index 00000000..f7b8dceb --- /dev/null +++ b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/logcat/LogCatViewerFilter.java @@ -0,0 +1,46 @@ +/* + * Copyright (C) 2011 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.ddmuilib.logcat; + +import org.eclipse.jface.viewers.Viewer; +import org.eclipse.jface.viewers.ViewerFilter; + +/** + * A JFace {@link ViewerFilter} for the {@link LogCatPanel} displaying logcat messages. + * This is a simple wrapper around {@link LogCatFilter}. + */ +public final class LogCatViewerFilter extends ViewerFilter { + private LogCatFilter mFilter; + + /** + * Construct a {@link ViewerFilter} filtering logcat messages based on + * user provided filter settings for PID, Tag and log level. + * @param filter filter to use + */ + public LogCatViewerFilter(LogCatFilter filter) { + mFilter = filter; + } + + @Override + public boolean select(Viewer viewer, Object parent, Object element) { + if (!(element instanceof LogCatMessage)) { + return false; + } + + LogCatMessage m = (LogCatMessage) element; + return mFilter.matches(m); + } +} diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/logcat/LogColors.java b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/logcat/LogColors.java new file mode 100644 index 00000000..9cff6568 --- /dev/null +++ b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/logcat/LogColors.java @@ -0,0 +1,27 @@ +/* + * Copyright (C) 2007 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.ddmuilib.logcat; + +import org.eclipse.swt.graphics.Color; + +public class LogColors { + public Color infoColor; + public Color debugColor; + public Color errorColor; + public Color warningColor; + public Color verboseColor; +} diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/logcat/LogFilter.java b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/logcat/LogFilter.java new file mode 100644 index 00000000..74a5e37f --- /dev/null +++ b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/logcat/LogFilter.java @@ -0,0 +1,556 @@ +/* + * Copyright (C) 2007 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.ddmuilib.logcat; + +import com.android.ddmlib.Log; +import com.android.ddmlib.Log.LogLevel; +import com.android.ddmuilib.annotation.UiThread; +import com.android.ddmuilib.logcat.LogPanel.LogMessage; + +import org.eclipse.swt.SWT; +import org.eclipse.swt.SWTException; +import org.eclipse.swt.widgets.ScrollBar; +import org.eclipse.swt.widgets.TabItem; +import org.eclipse.swt.widgets.Table; +import org.eclipse.swt.widgets.TableItem; + +import java.util.ArrayList; +import java.util.regex.PatternSyntaxException; + +/** logcat output filter class */ +public class LogFilter { + + public final static int MODE_PID = 0x01; + public final static int MODE_TAG = 0x02; + public final static int MODE_LEVEL = 0x04; + + private String mName; + + /** + * Filtering mode. Value can be a mix of MODE_PID, MODE_TAG, MODE_LEVEL + */ + private int mMode = 0; + + /** + * pid used for filtering. Only valid if mMode is MODE_PID. + */ + private int mPid; + + /** Single level log level as defined in Log.mLevelChar. Only valid + * if mMode is MODE_LEVEL */ + private int mLogLevel; + + /** + * log tag filtering. Only valid if mMode is MODE_TAG + */ + private String mTag; + + private Table mTable; + private TabItem mTabItem; + private boolean mIsCurrentTabItem = false; + private int mUnreadCount = 0; + + /** Temp keyword filtering */ + private String[] mTempKeywordFilters; + + /** temp pid filtering */ + private int mTempPid = -1; + + /** temp tag filtering */ + private String mTempTag; + + /** temp log level filtering */ + private int mTempLogLevel = -1; + + private LogColors mColors; + + private boolean mTempFilteringStatus = false; + + private final ArrayList mMessages = new ArrayList(); + private final ArrayList mNewMessages = new ArrayList(); + + private boolean mSupportsDelete = true; + private boolean mSupportsEdit = true; + private int mRemovedMessageCount = 0; + + /** + * Creates a filter with a particular mode. + * @param name The name to be displayed in the UI + */ + public LogFilter(String name) { + mName = name; + } + + public LogFilter() { + + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(mName); + + sb.append(':'); + sb.append(mMode); + if ((mMode & MODE_PID) == MODE_PID) { + sb.append(':'); + sb.append(mPid); + } + + if ((mMode & MODE_LEVEL) == MODE_LEVEL) { + sb.append(':'); + sb.append(mLogLevel); + } + + if ((mMode & MODE_TAG) == MODE_TAG) { + sb.append(':'); + sb.append(mTag); + } + + return sb.toString(); + } + + public boolean loadFromString(String string) { + String[] segments = string.split(":"); //$NON-NLS-1$ + int index = 0; + + // get the name + mName = segments[index++]; + + // get the mode + mMode = Integer.parseInt(segments[index++]); + + if ((mMode & MODE_PID) == MODE_PID) { + mPid = Integer.parseInt(segments[index++]); + } + + if ((mMode & MODE_LEVEL) == MODE_LEVEL) { + mLogLevel = Integer.parseInt(segments[index++]); + } + + if ((mMode & MODE_TAG) == MODE_TAG) { + mTag = segments[index++]; + } + + return true; + } + + + /** Sets the name of the filter. */ + void setName(String name) { + mName = name; + } + + /** + * Returns the UI display name. + */ + public String getName() { + return mName; + } + + /** + * Set the Table ui widget associated with this filter. + * @param tabItem The item in the TabFolder + * @param table The Table object + */ + public void setWidgets(TabItem tabItem, Table table) { + mTable = table; + mTabItem = tabItem; + } + + /** + * Returns true if the filter is ready for ui. + */ + public boolean uiReady() { + return (mTable != null && mTabItem != null); + } + + /** + * Returns the UI table object. + * @return + */ + public Table getTable() { + return mTable; + } + + public void dispose() { + mTable.dispose(); + mTabItem.dispose(); + mTable = null; + mTabItem = null; + } + + /** + * Resets the filtering mode to be 0 (i.e. no filter). + */ + public void resetFilteringMode() { + mMode = 0; + } + + /** + * Returns the current filtering mode. + * @return A bitmask. Possible values are MODE_PID, MODE_TAG, MODE_LEVEL + */ + public int getFilteringMode() { + return mMode; + } + + /** + * Adds PID to the current filtering mode. + * @param pid + */ + public void setPidMode(int pid) { + if (pid != -1) { + mMode |= MODE_PID; + } else { + mMode &= ~MODE_PID; + } + mPid = pid; + } + + /** Returns the pid filter if valid, otherwise -1 */ + public int getPidFilter() { + if ((mMode & MODE_PID) == MODE_PID) + return mPid; + return -1; + } + + public void setTagMode(String tag) { + if (tag != null && tag.length() > 0) { + mMode |= MODE_TAG; + } else { + mMode &= ~MODE_TAG; + } + mTag = tag; + } + + public String getTagFilter() { + if ((mMode & MODE_TAG) == MODE_TAG) + return mTag; + return null; + } + + public void setLogLevel(int level) { + if (level == -1) { + mMode &= ~MODE_LEVEL; + } else { + mMode |= MODE_LEVEL; + mLogLevel = level; + } + + } + + public int getLogLevel() { + if ((mMode & MODE_LEVEL) == MODE_LEVEL) { + return mLogLevel; + } + + return -1; + } + + + public boolean supportsDelete() { + return mSupportsDelete ; + } + + public boolean supportsEdit() { + return mSupportsEdit; + } + + /** + * Sets the selected state of the filter. + * @param selected selection state. + */ + public void setSelectedState(boolean selected) { + if (selected) { + if (mTabItem != null) { + mTabItem.setText(mName); + } + mUnreadCount = 0; + } + mIsCurrentTabItem = selected; + } + + /** + * Adds a new message and optionally removes an old message. + *

The new message is filtered through {@link #accept(LogMessage)}. + * Calls to {@link #flush()} from a UI thread will display it (and other + * pending messages) to the associated {@link Table}. + * @param logMessage the MessageData object to filter + * @return true if the message was accepted. + */ + public boolean addMessage(LogMessage newMessage, LogMessage oldMessage) { + synchronized (mMessages) { + if (oldMessage != null) { + int index = mMessages.indexOf(oldMessage); + if (index != -1) { + // TODO check that index will always be -1 or 0, as only the oldest message is ever removed. + mMessages.remove(index); + mRemovedMessageCount++; + } + + // now we look for it in mNewMessages. This can happen if the new message is added + // and then removed because too many messages are added between calls to #flush() + index = mNewMessages.indexOf(oldMessage); + if (index != -1) { + // TODO check that index will always be -1 or 0, as only the oldest message is ever removed. + mNewMessages.remove(index); + } + } + + boolean filter = accept(newMessage); + + if (filter) { + // at this point the message is accepted, we add it to the list + mMessages.add(newMessage); + mNewMessages.add(newMessage); + } + + return filter; + } + } + + /** + * Removes all the items in the filter and its {@link Table}. + */ + public void clear() { + mRemovedMessageCount = 0; + mNewMessages.clear(); + mMessages.clear(); + mTable.removeAll(); + } + + /** + * Filters a message. + * @param logMessage the Message + * @return true if the message is accepted by the filter. + */ + boolean accept(LogMessage logMessage) { + // do the regular filtering now + if ((mMode & MODE_PID) == MODE_PID && mPid != logMessage.data.pid) { + return false; + } + + if ((mMode & MODE_TAG) == MODE_TAG && ( + logMessage.data.tag == null || + logMessage.data.tag.equals(mTag) == false)) { + return false; + } + + int msgLogLevel = logMessage.data.logLevel.getPriority(); + + // test the temp log filtering first, as it replaces the old one + if (mTempLogLevel != -1) { + if (mTempLogLevel > msgLogLevel) { + return false; + } + } else if ((mMode & MODE_LEVEL) == MODE_LEVEL && + mLogLevel > msgLogLevel) { + return false; + } + + // do the temp filtering now. + if (mTempKeywordFilters != null) { + String msg = logMessage.msg; + + for (String kw : mTempKeywordFilters) { + try { + if (msg.contains(kw) == false && msg.matches(kw) == false) { + return false; + } + } catch (PatternSyntaxException e) { + // if the string is not a valid regular expression, + // this exception is thrown. + return false; + } + } + } + + if (mTempPid != -1 && mTempPid != logMessage.data.pid) { + return false; + } + + if (mTempTag != null && mTempTag.length() > 0) { + if (mTempTag.equals(logMessage.data.tag) == false) { + return false; + } + } + + return true; + } + + /** + * Takes all the accepted messages and display them. + * This must be called from a UI thread. + */ + @UiThread + public void flush() { + // if scroll bar is at the bottom, we will scroll + ScrollBar bar = mTable.getVerticalBar(); + boolean scroll = bar.getMaximum() == bar.getSelection() + bar.getThumb(); + + // if we are not going to scroll, get the current first item being shown. + int topIndex = mTable.getTopIndex(); + + // disable drawing + mTable.setRedraw(false); + + int totalCount = mNewMessages.size(); + + try { + // remove the items of the old messages. + for (int i = 0 ; i < mRemovedMessageCount && mTable.getItemCount() > 0 ; i++) { + mTable.remove(0); + } + mRemovedMessageCount = 0; + + if (mUnreadCount > mTable.getItemCount()) { + mUnreadCount = mTable.getItemCount(); + } + + // add the new items + for (int i = 0 ; i < totalCount ; i++) { + LogMessage msg = mNewMessages.get(i); + addTableItem(msg); + } + } catch (SWTException e) { + // log the error and keep going. Content of the logcat table maybe unexpected + // but at least ddms won't crash. + Log.e("LogFilter", e); + } + + // redraw + mTable.setRedraw(true); + + // scroll if needed, by showing the last item + if (scroll) { + totalCount = mTable.getItemCount(); + if (totalCount > 0) { + mTable.showItem(mTable.getItem(totalCount-1)); + } + } else if (mRemovedMessageCount > 0) { + // we need to make sure the topIndex is still visible. + // Because really old items are removed from the list, this could make it disappear + // if we don't change the scroll value at all. + + topIndex -= mRemovedMessageCount; + if (topIndex < 0) { + // looks like it disappeared. Lets just show the first item + mTable.showItem(mTable.getItem(0)); + } else { + mTable.showItem(mTable.getItem(topIndex)); + } + } + + // if this filter is not the current one, we update the tab text + // with the amount of unread message + if (mIsCurrentTabItem == false) { + mUnreadCount += mNewMessages.size(); + totalCount = mTable.getItemCount(); + if (mUnreadCount > 0) { + mTabItem.setText(mName + " (" //$NON-NLS-1$ + + (mUnreadCount > totalCount ? totalCount : mUnreadCount) + + ")"); //$NON-NLS-1$ + } else { + mTabItem.setText(mName); //$NON-NLS-1$ + } + } + + mNewMessages.clear(); + } + + void setColors(LogColors colors) { + mColors = colors; + } + + int getUnreadCount() { + return mUnreadCount; + } + + void setUnreadCount(int unreadCount) { + mUnreadCount = unreadCount; + } + + void setSupportsDelete(boolean support) { + mSupportsDelete = support; + } + + void setSupportsEdit(boolean support) { + mSupportsEdit = support; + } + + void setTempKeywordFiltering(String[] segments) { + mTempKeywordFilters = segments; + mTempFilteringStatus = true; + } + + void setTempPidFiltering(int pid) { + mTempPid = pid; + mTempFilteringStatus = true; + } + + void setTempTagFiltering(String tag) { + mTempTag = tag; + mTempFilteringStatus = true; + } + + void resetTempFiltering() { + if (mTempPid != -1 || mTempTag != null || mTempKeywordFilters != null) { + mTempFilteringStatus = true; + } + + mTempPid = -1; + mTempTag = null; + mTempKeywordFilters = null; + } + + void resetTempFilteringStatus() { + mTempFilteringStatus = false; + } + + boolean getTempFilterStatus() { + return mTempFilteringStatus; + } + + + /** + * Add a TableItem for the index-th item of the buffer + * @param filter The index of the table in which to insert the item. + */ + private void addTableItem(LogMessage msg) { + TableItem item = new TableItem(mTable, SWT.NONE); + item.setText(0, msg.data.time); + item.setText(1, new String(new char[] { msg.data.logLevel.getPriorityLetter() })); + item.setText(2, msg.data.pidString); + item.setText(3, msg.data.tag); + item.setText(4, msg.msg); + + // add the buffer index as data + item.setData(msg); + + if (msg.data.logLevel == LogLevel.INFO) { + item.setForeground(mColors.infoColor); + } else if (msg.data.logLevel == LogLevel.DEBUG) { + item.setForeground(mColors.debugColor); + } else if (msg.data.logLevel == LogLevel.ERROR) { + item.setForeground(mColors.errorColor); + } else if (msg.data.logLevel == LogLevel.WARN) { + item.setForeground(mColors.warningColor); + } else { + item.setForeground(mColors.verboseColor); + } + } +} diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/logcat/LogPanel.java b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/logcat/LogPanel.java new file mode 100644 index 00000000..d60bae8f --- /dev/null +++ b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/logcat/LogPanel.java @@ -0,0 +1,1617 @@ +/* + * Copyright (C) 2007 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.ddmuilib.logcat; + +import com.android.ddmlib.AdbCommandRejectedException; +import com.android.ddmlib.IDevice; +import com.android.ddmlib.Log; +import com.android.ddmlib.Log.LogLevel; +import com.android.ddmlib.MultiLineReceiver; +import com.android.ddmlib.ShellCommandUnresponsiveException; +import com.android.ddmlib.TimeoutException; +import com.android.ddmuilib.DdmUiPreferences; +import com.android.ddmuilib.ITableFocusListener; +import com.android.ddmuilib.ITableFocusListener.IFocusedTableActivator; +import com.android.ddmuilib.SelectionDependentPanel; +import com.android.ddmuilib.TableHelper; +import com.android.ddmuilib.actions.ICommonAction; + +import org.eclipse.jface.preference.IPreferenceStore; +import org.eclipse.swt.SWT; +import org.eclipse.swt.SWTException; +import org.eclipse.swt.dnd.Clipboard; +import org.eclipse.swt.dnd.TextTransfer; +import org.eclipse.swt.dnd.Transfer; +import org.eclipse.swt.events.ControlEvent; +import org.eclipse.swt.events.ControlListener; +import org.eclipse.swt.events.FocusEvent; +import org.eclipse.swt.events.FocusListener; +import org.eclipse.swt.events.ModifyEvent; +import org.eclipse.swt.events.ModifyListener; +import org.eclipse.swt.events.SelectionAdapter; +import org.eclipse.swt.events.SelectionEvent; +import org.eclipse.swt.graphics.Font; +import org.eclipse.swt.graphics.Rectangle; +import org.eclipse.swt.layout.FillLayout; +import org.eclipse.swt.layout.GridData; +import org.eclipse.swt.layout.GridLayout; +import org.eclipse.swt.widgets.Composite; +import org.eclipse.swt.widgets.Control; +import org.eclipse.swt.widgets.Display; +import org.eclipse.swt.widgets.FileDialog; +import org.eclipse.swt.widgets.Label; +import org.eclipse.swt.widgets.TabFolder; +import org.eclipse.swt.widgets.TabItem; +import org.eclipse.swt.widgets.Table; +import org.eclipse.swt.widgets.TableColumn; +import org.eclipse.swt.widgets.TableItem; +import org.eclipse.swt.widgets.Text; + +import java.io.FileWriter; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class LogPanel extends SelectionDependentPanel { + + private static final int STRING_BUFFER_LENGTH = 10000; + + /** no filtering. Only one tab with everything. */ + public static final int FILTER_NONE = 0; + /** manual mode for filter. all filters are manually created. */ + public static final int FILTER_MANUAL = 1; + /** automatic mode for filter (pid mode). + * All filters are automatically created. */ + public static final int FILTER_AUTO_PID = 2; + /** automatic mode for filter (tag mode). + * All filters are automatically created. */ + public static final int FILTER_AUTO_TAG = 3; + /** Manual filtering mode + new filter for debug app, if needed */ + public static final int FILTER_DEBUG = 4; + + public static final int COLUMN_MODE_MANUAL = 0; + public static final int COLUMN_MODE_AUTO = 1; + + public static String PREFS_TIME; + public static String PREFS_LEVEL; + public static String PREFS_PID; + public static String PREFS_TAG; + public static String PREFS_MESSAGE; + + /** + * This pattern is meant to parse the first line of a log message with the option + * 'logcat -v long'. The first line represents the date, tag, severity, etc.. while the + * following lines are the message (can be several line).
+ * This first line looks something like
+ * "[ 00-00 00:00:00.000 <pid>:0x<???> <severity>/<tag>]" + *
+ * Note: severity is one of V, D, I, W, or EM
+ * Note: the fraction of second value can have any number of digit. + * Note the tag should be trim as it may have spaces at the end. + */ + private static Pattern sLogPattern = Pattern.compile( + "^\\[\\s(\\d\\d-\\d\\d\\s\\d\\d:\\d\\d:\\d\\d\\.\\d+)" + //$NON-NLS-1$ + "\\s+(\\d*):(0x[0-9a-fA-F]+)\\s([VDIWE])/(.*)\\]$"); //$NON-NLS-1$ + + /** + * Interface for Storage Filter manager. Implementation of this interface + * provide a custom way to archive an reload filters. + */ + public interface ILogFilterStorageManager { + + public LogFilter[] getFilterFromStore(); + + public void saveFilters(LogFilter[] filters); + + public boolean requiresDefaultFilter(); + } + + private Composite mParent; + private IPreferenceStore mStore; + + /** top object in the view */ + private TabFolder mFolders; + + private LogColors mColors; + + private ILogFilterStorageManager mFilterStorage; + + private LogCatOuputReceiver mCurrentLogCat; + + /** + * Circular buffer containing the logcat output. This is unfiltered. + * The valid content goes from mBufferStart to + * mBufferEnd - 1. Therefore its number of item is + * mBufferEnd - mBufferStart. + */ + private LogMessage[] mBuffer = new LogMessage[STRING_BUFFER_LENGTH]; + + /** Represents the oldest message in the buffer */ + private int mBufferStart = -1; + + /** + * Represents the next usable item in the buffer to receive new message. + * This can be equal to mBufferStart, but when used mBufferStart will be + * incremented as well. + */ + private int mBufferEnd = -1; + + /** Filter list */ + private LogFilter[] mFilters; + + /** Default filter */ + private LogFilter mDefaultFilter; + + /** Current filter being displayed */ + private LogFilter mCurrentFilter; + + /** Filtering mode */ + private int mFilterMode = FILTER_NONE; + + /** Device currently running logcat */ + private IDevice mCurrentLoggedDevice = null; + + private ICommonAction mDeleteFilterAction; + private ICommonAction mEditFilterAction; + + private ICommonAction[] mLogLevelActions; + + /** message data, separated from content for multi line messages */ + protected static class LogMessageInfo { + public LogLevel logLevel; + public int pid; + public String pidString; + public String tag; + public String time; + } + + /** pointer to the latest LogMessageInfo. this is used for multi line + * log message, to reuse the info regarding level, pid, etc... + */ + private LogMessageInfo mLastMessageInfo = null; + + private boolean mPendingAsyncRefresh = false; + + private String mDefaultLogSave; + + private int mColumnMode = COLUMN_MODE_MANUAL; + private Font mDisplayFont; + + private ITableFocusListener mGlobalListener; + + private LogCatViewInterface mLogCatViewInterface = null; + + /** message data, separated from content for multi line messages */ + protected static class LogMessage { + public LogMessageInfo data; + public String msg; + + @Override + public String toString() { + return data.time + ": " //$NON-NLS-1$ + + data.logLevel + "/" //$NON-NLS-1$ + + data.tag + "(" //$NON-NLS-1$ + + data.pidString + "): " //$NON-NLS-1$ + + msg; + } + } + + /** + * objects able to receive the output of a remote shell command, + * specifically a logcat command in this case + */ + private final class LogCatOuputReceiver extends MultiLineReceiver { + + public boolean isCancelled = false; + + public LogCatOuputReceiver() { + super(); + + setTrimLine(false); + } + + @Override + public void processNewLines(String[] lines) { + if (isCancelled == false) { + processLogLines(lines); + } + } + + @Override + public boolean isCancelled() { + return isCancelled; + } + } + + /** + * Parser class for the output of a "ps" shell command executed on a device. + * This class looks for a specific pid to find the process name from it. + * Once found, the name is used to update a filter and a tab object + * + */ + private class PsOutputReceiver extends MultiLineReceiver { + + private LogFilter mFilter; + + private TabItem mTabItem; + + private int mPid; + + /** set to true when we've found the pid we're looking for */ + private boolean mDone = false; + + PsOutputReceiver(int pid, LogFilter filter, TabItem tabItem) { + mPid = pid; + mFilter = filter; + mTabItem = tabItem; + } + + @Override + public boolean isCancelled() { + return mDone; + } + + @Override + public void processNewLines(String[] lines) { + for (String line : lines) { + if (line.startsWith("USER")) { //$NON-NLS-1$ + continue; + } + // get the pid. + int index = line.indexOf(' '); + if (index == -1) { + continue; + } + // look for the next non blank char + index++; + while (line.charAt(index) == ' ') { + index++; + } + + // this is the start of the pid. + // look for the end. + int index2 = line.indexOf(' ', index); + + // get the line + String pidStr = line.substring(index, index2); + int pid = Integer.parseInt(pidStr); + if (pid != mPid) { + continue; + } else { + // get the process name + index = line.lastIndexOf(' '); + final String name = line.substring(index + 1); + + mFilter.setName(name); + + // update the tab + Display d = mFolders.getDisplay(); + d.asyncExec(new Runnable() { + @Override + public void run() { + mTabItem.setText(name); + } + }); + + // we're done with this ps. + mDone = true; + return; + } + } + } + + } + + /** + * Interface implemented by the LogCatView in Eclipse for particular action on double-click. + */ + public interface LogCatViewInterface { + public void onDoubleClick(); + } + + /** + * Create the log view with some default parameters + * @param colors The display color object + * @param filterStorage the storage for user defined filters. + * @param mode The filtering mode + */ + public LogPanel(LogColors colors, + ILogFilterStorageManager filterStorage, int mode) { + mColors = colors; + mFilterMode = mode; + mFilterStorage = filterStorage; + mStore = DdmUiPreferences.getStore(); + } + + public void setActions(ICommonAction deleteAction, ICommonAction editAction, + ICommonAction[] logLevelActions) { + mDeleteFilterAction = deleteAction; + mEditFilterAction = editAction; + mLogLevelActions = logLevelActions; + } + + /** + * Sets the column mode. Must be called before creatUI + * @param mode the column mode. Valid values are COLUMN_MOD_MANUAL and + * COLUMN_MODE_AUTO + */ + public void setColumnMode(int mode) { + mColumnMode = mode; + } + + /** + * Sets the display font. + * @param font The display font. + */ + public void setFont(Font font) { + mDisplayFont = font; + + if (mFilters != null) { + for (LogFilter f : mFilters) { + Table table = f.getTable(); + if (table != null) { + table.setFont(font); + } + } + } + + if (mDefaultFilter != null) { + Table table = mDefaultFilter.getTable(); + if (table != null) { + table.setFont(font); + } + } + } + + /** + * Sent when a new device is selected. The new device can be accessed + * with {@link #getCurrentDevice()}. + */ + @Override + public void deviceSelected() { + startLogCat(getCurrentDevice()); + } + + /** + * Sent when a new client is selected. The new client can be accessed + * with {@link #getCurrentClient()}. + */ + @Override + public void clientSelected() { + // pass + } + + + /** + * Creates a control capable of displaying some information. This is + * called once, when the application is initializing, from the UI thread. + */ + @Override + protected Control createControl(Composite parent) { + mParent = parent; + + Composite top = new Composite(parent, SWT.NONE); + top.setLayoutData(new GridData(GridData.FILL_BOTH)); + top.setLayout(new GridLayout(1, false)); + + // create the tab folder + mFolders = new TabFolder(top, SWT.NONE); + mFolders.setLayoutData(new GridData(GridData.FILL_BOTH)); + mFolders.addSelectionListener(new SelectionAdapter() { + @Override + public void widgetSelected(SelectionEvent e) { + if (mCurrentFilter != null) { + mCurrentFilter.setSelectedState(false); + } + mCurrentFilter = getCurrentFilter(); + mCurrentFilter.setSelectedState(true); + updateColumns(mCurrentFilter.getTable()); + if (mCurrentFilter.getTempFilterStatus()) { + initFilter(mCurrentFilter); + } + selectionChanged(mCurrentFilter); + } + }); + + + Composite bottom = new Composite(top, SWT.NONE); + bottom.setLayoutData(new GridData(GridData.FILL_HORIZONTAL)); + bottom.setLayout(new GridLayout(3, false)); + + Label label = new Label(bottom, SWT.NONE); + label.setText("Filter:"); + + final Text filterText = new Text(bottom, SWT.SINGLE | SWT.BORDER); + filterText.setLayoutData(new GridData(GridData.FILL_HORIZONTAL)); + filterText.addModifyListener(new ModifyListener() { + @Override + public void modifyText(ModifyEvent e) { + updateFilteringWith(filterText.getText()); + } + }); + + /* + Button addFilterBtn = new Button(bottom, SWT.NONE); + addFilterBtn.setImage(mImageLoader.loadImage("add.png", //$NON-NLS-1$ + addFilterBtn.getDisplay())); + */ + + // get the filters + createFilters(); + + // for each filter, create a tab. + int index = 0; + + if (mDefaultFilter != null) { + createTab(mDefaultFilter, index++, false); + } + + if (mFilters != null) { + for (LogFilter f : mFilters) { + createTab(f, index++, false); + } + } + + return top; + } + + @Override + protected void postCreation() { + // pass + } + + /** + * Sets the focus to the proper object. + */ + @Override + public void setFocus() { + mFolders.setFocus(); + } + + + /** + * Starts a new logcat and set mCurrentLogCat as the current receiver. + * @param device the device to connect logcat to. + */ + public void startLogCat(final IDevice device) { + if (device == mCurrentLoggedDevice) { + return; + } + + // if we have a logcat already running + if (mCurrentLoggedDevice != null) { + stopLogCat(false); + mCurrentLoggedDevice = null; + } + + resetUI(false); + + if (device != null) { + // create a new output receiver + mCurrentLogCat = new LogCatOuputReceiver(); + + // start the logcat in a different thread + new Thread("Logcat") { //$NON-NLS-1$ + @Override + public void run() { + + while (device.isOnline() == false && + mCurrentLogCat != null && + mCurrentLogCat.isCancelled == false) { + try { + sleep(2000); + } catch (InterruptedException e) { + return; + } + } + + if (mCurrentLogCat == null || mCurrentLogCat.isCancelled) { + // logcat was stopped/cancelled before the device became ready. + return; + } + + try { + mCurrentLoggedDevice = device; + device.executeShellCommand("logcat -v long", mCurrentLogCat, 0 /*timeout*/); //$NON-NLS-1$ + } catch (Exception e) { + Log.e("Logcat", e); + } finally { + // at this point the command is terminated. + mCurrentLogCat = null; + mCurrentLoggedDevice = null; + } + } + }.start(); + } + } + + /** Stop the current logcat */ + public void stopLogCat(boolean inUiThread) { + if (mCurrentLogCat != null) { + mCurrentLogCat.isCancelled = true; + + // when the thread finishes, no one will reference that object + // and it'll be destroyed + mCurrentLogCat = null; + + // reset the content buffer + for (int i = 0 ; i < STRING_BUFFER_LENGTH; i++) { + mBuffer[i] = null; + } + + // because it's a circular buffer, it's hard to know if + // the array is empty with both start/end at 0 or if it's full + // with both start/end at 0 as well. So to mean empty, we use -1 + mBufferStart = -1; + mBufferEnd = -1; + + resetFilters(); + resetUI(inUiThread); + } + } + + /** + * Adds a new Filter. This methods displays the UI to create the filter + * and set up its parameters.
+ * MUST be called from the ui thread. + * + */ + public void addFilter() { + EditFilterDialog dlg = new EditFilterDialog(mFolders.getShell()); + if (dlg.open()) { + synchronized (mBuffer) { + // get the new filter in the array + LogFilter filter = dlg.getFilter(); + addFilterToArray(filter); + + int index = mFilters.length - 1; + if (mDefaultFilter != null) { + index++; + } + + if (false) { + + for (LogFilter f : mFilters) { + if (f.uiReady()) { + f.dispose(); + } + } + if (mDefaultFilter != null && mDefaultFilter.uiReady()) { + mDefaultFilter.dispose(); + } + + // for each filter, create a tab. + int i = 0; + if (mFilters != null) { + for (LogFilter f : mFilters) { + createTab(f, i++, true); + } + } + if (mDefaultFilter != null) { + createTab(mDefaultFilter, i++, true); + } + } else { + + // create ui for the filter. + createTab(filter, index, true); + + // reset the default as it shouldn't contain the content of + // this new filter. + if (mDefaultFilter != null) { + initDefaultFilter(); + } + } + + // select the new filter + if (mCurrentFilter != null) { + mCurrentFilter.setSelectedState(false); + } + mFolders.setSelection(index); + filter.setSelectedState(true); + mCurrentFilter = filter; + + selectionChanged(filter); + + // finally we update the filtering mode if needed + if (mFilterMode == FILTER_NONE) { + mFilterMode = FILTER_MANUAL; + } + + mFilterStorage.saveFilters(mFilters); + + } + } + } + + /** + * Edits the current filter. The method displays the UI to edit the filter. + */ + public void editFilter() { + if (mCurrentFilter != null && mCurrentFilter != mDefaultFilter) { + EditFilterDialog dlg = new EditFilterDialog( + mFolders.getShell(), mCurrentFilter); + if (dlg.open()) { + synchronized (mBuffer) { + // at this point the filter has been updated. + // so we update its content + initFilter(mCurrentFilter); + + // and the content of the "other" filter as well. + if (mDefaultFilter != null) { + initDefaultFilter(); + } + + mFilterStorage.saveFilters(mFilters); + } + } + } + } + + /** + * Deletes the current filter. + */ + public void deleteFilter() { + synchronized (mBuffer) { + if (mCurrentFilter != null && mCurrentFilter != mDefaultFilter) { + // remove the filter from the list + removeFilterFromArray(mCurrentFilter); + mCurrentFilter.dispose(); + + // select the new filter + mFolders.setSelection(0); + if (mFilters.length > 0) { + mCurrentFilter = mFilters[0]; + } else { + mCurrentFilter = mDefaultFilter; + } + + selectionChanged(mCurrentFilter); + + // update the content of the "other" filter to include what was filtered out + // by the deleted filter. + if (mDefaultFilter != null) { + initDefaultFilter(); + } + + mFilterStorage.saveFilters(mFilters); + } + } + } + + /** + * saves the current selection in a text file. + * @return false if the saving failed. + */ + public boolean save() { + synchronized (mBuffer) { + FileDialog dlg = new FileDialog(mParent.getShell(), SWT.SAVE); + String fileName; + + dlg.setText("Save log..."); + dlg.setFileName("log.txt"); + String defaultPath = mDefaultLogSave; + if (defaultPath == null) { + defaultPath = System.getProperty("user.home"); //$NON-NLS-1$ + } + dlg.setFilterPath(defaultPath); + dlg.setFilterNames(new String[] { + "Text Files (*.txt)" + }); + dlg.setFilterExtensions(new String[] { + "*.txt" + }); + + fileName = dlg.open(); + if (fileName != null) { + mDefaultLogSave = dlg.getFilterPath(); + + // get the current table and its selection + Table currentTable = mCurrentFilter.getTable(); + + int[] selection = currentTable.getSelectionIndices(); + + // we need to sort the items to be sure. + Arrays.sort(selection); + + // loop on the selection and output the file. + try { + FileWriter writer = new FileWriter(fileName); + + for (int i : selection) { + TableItem item = currentTable.getItem(i); + LogMessage msg = (LogMessage)item.getData(); + String line = msg.toString(); + writer.write(line); + writer.write('\n'); + } + writer.flush(); + + } catch (IOException e) { + return false; + } + } + } + + return true; + } + + /** + * Empty the current circular buffer. + */ + public void clear() { + synchronized (mBuffer) { + for (int i = 0 ; i < STRING_BUFFER_LENGTH; i++) { + mBuffer[i] = null; + } + + mBufferStart = -1; + mBufferEnd = -1; + + // now we clear the existing filters + for (LogFilter filter : mFilters) { + filter.clear(); + } + + // and the default one + if (mDefaultFilter != null) { + mDefaultFilter.clear(); + } + } + } + + /** + * Copies the current selection of the current filter as multiline text. + * + * @param clipboard The clipboard to place the copied content. + */ + public void copy(Clipboard clipboard) { + // get the current table and its selection + Table currentTable = mCurrentFilter.getTable(); + + copyTable(clipboard, currentTable); + } + + /** + * Selects all lines. + */ + public void selectAll() { + Table currentTable = mCurrentFilter.getTable(); + currentTable.selectAll(); + } + + /** + * Sets a TableFocusListener which will be notified when one of the tables + * gets or loses focus. + * + * @param listener + */ + public void setTableFocusListener(ITableFocusListener listener) { + // record the global listener, to make sure table created after + // this call will still be setup. + mGlobalListener = listener; + + // now we setup the existing filters + for (LogFilter filter : mFilters) { + Table table = filter.getTable(); + + addTableToFocusListener(table); + } + + // and the default one + if (mDefaultFilter != null) { + addTableToFocusListener(mDefaultFilter.getTable()); + } + } + + /** + * Sets up a Table object to notify the global Table Focus listener when it + * gets or loses the focus. + * + * @param table the Table object. + */ + private void addTableToFocusListener(final Table table) { + // create the activator for this table + final IFocusedTableActivator activator = new IFocusedTableActivator() { + @Override + public void copy(Clipboard clipboard) { + copyTable(clipboard, table); + } + + @Override + public void selectAll() { + table.selectAll(); + } + }; + + // add the focus listener on the table to notify the global listener + table.addFocusListener(new FocusListener() { + @Override + public void focusGained(FocusEvent e) { + mGlobalListener.focusGained(activator); + } + + @Override + public void focusLost(FocusEvent e) { + mGlobalListener.focusLost(activator); + } + }); + } + + /** + * Copies the current selection of a Table into the provided Clipboard, as + * multi-line text. + * + * @param clipboard The clipboard to place the copied content. + * @param table The table to copy from. + */ + private static void copyTable(Clipboard clipboard, Table table) { + int[] selection = table.getSelectionIndices(); + + // we need to sort the items to be sure. + Arrays.sort(selection); + + // all lines must be concatenated. + StringBuilder sb = new StringBuilder(); + + // loop on the selection and output the file. + for (int i : selection) { + TableItem item = table.getItem(i); + LogMessage msg = (LogMessage)item.getData(); + String line = msg.toString(); + sb.append(line); + sb.append('\n'); + } + + // now add that to the clipboard + clipboard.setContents(new Object[] { + sb.toString() + }, new Transfer[] { + TextTransfer.getInstance() + }); + } + + /** + * Sets the log level for the current filter, but does not save it. + * @param i + */ + public void setCurrentFilterLogLevel(int i) { + LogFilter filter = getCurrentFilter(); + + filter.setLogLevel(i); + + initFilter(filter); + } + + /** + * Creates a new tab in the folderTab item. Must be called from the ui + * thread. + * @param filter The filter associated with the tab. + * @param index the index of the tab. if -1, the tab will be added at the + * end. + * @param fillTable If true the table is filled with the current content of + * the buffer. + * @return The TabItem object that was created. + */ + private TabItem createTab(LogFilter filter, int index, boolean fillTable) { + synchronized (mBuffer) { + TabItem item = null; + if (index != -1) { + item = new TabItem(mFolders, SWT.NONE, index); + } else { + item = new TabItem(mFolders, SWT.NONE); + } + item.setText(filter.getName()); + + // set the control (the parent is the TabFolder item, always) + Composite top = new Composite(mFolders, SWT.NONE); + item.setControl(top); + + top.setLayout(new FillLayout()); + + // create the ui, first the table + final Table t = new Table(top, SWT.MULTI | SWT.FULL_SELECTION); + t.addSelectionListener(new SelectionAdapter() { + @Override + public void widgetDefaultSelected(SelectionEvent e) { + if (mLogCatViewInterface != null) { + mLogCatViewInterface.onDoubleClick(); + } + } + }); + + if (mDisplayFont != null) { + t.setFont(mDisplayFont); + } + + // give the ui objects to the filters. + filter.setWidgets(item, t); + + t.setHeaderVisible(true); + t.setLinesVisible(false); + + if (mGlobalListener != null) { + addTableToFocusListener(t); + } + + // create a controllistener that will handle the resizing of all the + // columns (except the last) and of the table itself. + ControlListener listener = null; + if (mColumnMode == COLUMN_MODE_AUTO) { + listener = new ControlListener() { + @Override + public void controlMoved(ControlEvent e) { + } + + @Override + public void controlResized(ControlEvent e) { + Rectangle r = t.getClientArea(); + + // get the size of all but the last column + int total = t.getColumn(0).getWidth(); + total += t.getColumn(1).getWidth(); + total += t.getColumn(2).getWidth(); + total += t.getColumn(3).getWidth(); + + if (r.width > total) { + t.getColumn(4).setWidth(r.width-total); + } + } + }; + + t.addControlListener(listener); + } + + // then its column + TableColumn col = TableHelper.createTableColumn(t, "Time", SWT.LEFT, + "00-00 00:00:00", //$NON-NLS-1$ + PREFS_TIME, mStore); + if (mColumnMode == COLUMN_MODE_AUTO) { + col.addControlListener(listener); + } + + col = TableHelper.createTableColumn(t, "", SWT.CENTER, + "D", //$NON-NLS-1$ + PREFS_LEVEL, mStore); + if (mColumnMode == COLUMN_MODE_AUTO) { + col.addControlListener(listener); + } + + col = TableHelper.createTableColumn(t, "pid", SWT.LEFT, + "9999", //$NON-NLS-1$ + PREFS_PID, mStore); + if (mColumnMode == COLUMN_MODE_AUTO) { + col.addControlListener(listener); + } + + col = TableHelper.createTableColumn(t, "tag", SWT.LEFT, + "abcdefgh", //$NON-NLS-1$ + PREFS_TAG, mStore); + if (mColumnMode == COLUMN_MODE_AUTO) { + col.addControlListener(listener); + } + + col = TableHelper.createTableColumn(t, "Message", SWT.LEFT, + "abcdefghijklmnopqrstuvwxyz0123456789", //$NON-NLS-1$ + PREFS_MESSAGE, mStore); + if (mColumnMode == COLUMN_MODE_AUTO) { + // instead of listening on resize for the last column, we make + // it non resizable. + col.setResizable(false); + } + + if (fillTable) { + initFilter(filter); + } + return item; + } + } + + protected void updateColumns(Table table) { + if (table != null) { + int index = 0; + TableColumn col; + + col = table.getColumn(index++); + col.setWidth(mStore.getInt(PREFS_TIME)); + + col = table.getColumn(index++); + col.setWidth(mStore.getInt(PREFS_LEVEL)); + + col = table.getColumn(index++); + col.setWidth(mStore.getInt(PREFS_PID)); + + col = table.getColumn(index++); + col.setWidth(mStore.getInt(PREFS_TAG)); + + col = table.getColumn(index++); + col.setWidth(mStore.getInt(PREFS_MESSAGE)); + } + } + + public void resetUI(boolean inUiThread) { + if (mFilterMode == FILTER_AUTO_PID || mFilterMode == FILTER_AUTO_TAG) { + if (inUiThread) { + mFolders.dispose(); + mParent.pack(true); + createControl(mParent); + } else { + Display d = mFolders.getDisplay(); + + // run sync as we need to update right now. + d.syncExec(new Runnable() { + @Override + public void run() { + mFolders.dispose(); + mParent.pack(true); + createControl(mParent); + } + }); + } + } else { + // the ui is static we just empty it. + if (mFolders.isDisposed() == false) { + if (inUiThread) { + emptyTables(); + } else { + Display d = mFolders.getDisplay(); + + // run sync as we need to update right now. + d.syncExec(new Runnable() { + @Override + public void run() { + if (mFolders.isDisposed() == false) { + emptyTables(); + } + } + }); + } + } + } + } + + /** + * Process new Log lines coming from {@link LogCatOuputReceiver}. + * @param lines the new lines + */ + protected void processLogLines(String[] lines) { + // WARNING: this will not work if the string contains more line than + // the buffer holds. + + if (lines.length > STRING_BUFFER_LENGTH) { + Log.e("LogCat", "Receiving more lines than STRING_BUFFER_LENGTH"); + } + + // parse the lines and create LogMessage that are stored in a temporary list + final ArrayList newMessages = new ArrayList(); + + synchronized (mBuffer) { + for (String line : lines) { + // ignore empty lines. + if (line.length() > 0) { + // check for header lines. + Matcher matcher = sLogPattern.matcher(line); + if (matcher.matches()) { + // this is a header line, parse the header and keep it around. + mLastMessageInfo = new LogMessageInfo(); + + mLastMessageInfo.time = matcher.group(1); + mLastMessageInfo.pidString = matcher.group(2); + mLastMessageInfo.pid = Integer.valueOf(mLastMessageInfo.pidString); + mLastMessageInfo.logLevel = LogLevel.getByLetterString(matcher.group(4)); + mLastMessageInfo.tag = matcher.group(5).trim(); + } else { + // This is not a header line. + // Create a new LogMessage and process it. + LogMessage mc = new LogMessage(); + + if (mLastMessageInfo == null) { + // The first line of output wasn't preceded + // by a header line; make something up so + // that users of mc.data don't NPE. + mLastMessageInfo = new LogMessageInfo(); + mLastMessageInfo.time = "??-?? ??:??:??.???"; //$NON-NLS1$ + mLastMessageInfo.pidString = ""; //$NON-NLS1$ + mLastMessageInfo.pid = 0; + mLastMessageInfo.logLevel = LogLevel.INFO; + mLastMessageInfo.tag = ""; //$NON-NLS1$ + } + + // If someone printed a log message with + // embedded '\n' characters, there will + // one header line followed by multiple text lines. + // Use the last header that we saw. + mc.data = mLastMessageInfo; + + // tabs seem to display as only 1 tab so we replace the leading tabs + // by 4 spaces. + mc.msg = line.replaceAll("\t", " "); //$NON-NLS-1$ //$NON-NLS-2$ + + // process the new LogMessage. + processNewMessage(mc); + + // store the new LogMessage + newMessages.add(mc); + } + } + } + + // if we don't have a pending Runnable that will do the refresh, we ask the Display + // to run one in the UI thread. + if (mPendingAsyncRefresh == false) { + mPendingAsyncRefresh = true; + + try { + Display display = mFolders.getDisplay(); + + // run in sync because this will update the buffer start/end indices + display.asyncExec(new Runnable() { + @Override + public void run() { + asyncRefresh(); + } + }); + } catch (SWTException e) { + // display is disposed, we're probably quitting. Let's stop. + stopLogCat(false); + } + } + } + } + + /** + * Refreshes the UI with new messages. + */ + private void asyncRefresh() { + if (mFolders.isDisposed() == false) { + synchronized (mBuffer) { + try { + // the circular buffer has been updated, let have the filter flush their + // display with the new messages. + if (mFilters != null) { + for (LogFilter f : mFilters) { + f.flush(); + } + } + + if (mDefaultFilter != null) { + mDefaultFilter.flush(); + } + } finally { + // the pending refresh is done. + mPendingAsyncRefresh = false; + } + } + } else { + stopLogCat(true); + } + } + + /** + * Processes a new Message. + *

This adds the new message to the buffer, and gives it to the existing filters. + * @param newMessage + */ + private void processNewMessage(LogMessage newMessage) { + // if we are in auto filtering mode, make sure we have + // a filter for this + if (mFilterMode == FILTER_AUTO_PID || + mFilterMode == FILTER_AUTO_TAG) { + checkFilter(newMessage.data); + } + + // compute the index where the message goes. + // was the buffer empty? + int messageIndex = -1; + if (mBufferStart == -1) { + messageIndex = mBufferStart = 0; + mBufferEnd = 1; + } else { + messageIndex = mBufferEnd; + + // increment the next usable slot index + mBufferEnd = (mBufferEnd + 1) % STRING_BUFFER_LENGTH; + + // check we aren't overwriting start + if (mBufferEnd == mBufferStart) { + mBufferStart = (mBufferStart + 1) % STRING_BUFFER_LENGTH; + } + } + + LogMessage oldMessage = null; + + // record the message that was there before + if (mBuffer[messageIndex] != null) { + oldMessage = mBuffer[messageIndex]; + } + + // then add the new one + mBuffer[messageIndex] = newMessage; + + // give the new message to every filters. + boolean filtered = false; + if (mFilters != null) { + for (LogFilter f : mFilters) { + filtered |= f.addMessage(newMessage, oldMessage); + } + } + if (filtered == false && mDefaultFilter != null) { + mDefaultFilter.addMessage(newMessage, oldMessage); + } + } + + private void createFilters() { + if (mFilterMode == FILTER_DEBUG || mFilterMode == FILTER_MANUAL) { + // unarchive the filters. + mFilters = mFilterStorage.getFilterFromStore(); + + // set the colors + if (mFilters != null) { + for (LogFilter f : mFilters) { + f.setColors(mColors); + } + } + + if (mFilterStorage.requiresDefaultFilter()) { + mDefaultFilter = new LogFilter("Log"); + mDefaultFilter.setColors(mColors); + mDefaultFilter.setSupportsDelete(false); + mDefaultFilter.setSupportsEdit(false); + } + } else if (mFilterMode == FILTER_NONE) { + // if the filtering mode is "none", we create a single filter that + // will receive all + mDefaultFilter = new LogFilter("Log"); + mDefaultFilter.setColors(mColors); + mDefaultFilter.setSupportsDelete(false); + mDefaultFilter.setSupportsEdit(false); + } + } + + /** Checks if there's an automatic filter for this md and if not + * adds the filter and the ui. + * This must be called from the UI! + * @param md + * @return true if the filter existed already + */ + private boolean checkFilter(final LogMessageInfo md) { + if (true) + return true; + // look for a filter that matches the pid + if (mFilterMode == FILTER_AUTO_PID) { + for (LogFilter f : mFilters) { + if (f.getPidFilter() == md.pid) { + return true; + } + } + } else if (mFilterMode == FILTER_AUTO_TAG) { + for (LogFilter f : mFilters) { + if (f.getTagFilter().equals(md.tag)) { + return true; + } + } + } + + // if we reach this point, no filter was found. + // create a filter with a temporary name of the pid + final LogFilter newFilter = new LogFilter(md.pidString); + String name = null; + if (mFilterMode == FILTER_AUTO_PID) { + newFilter.setPidMode(md.pid); + + // ask the monitor thread if it knows the pid. + name = mCurrentLoggedDevice.getClientName(md.pid); + } else { + newFilter.setTagMode(md.tag); + name = md.tag; + } + addFilterToArray(newFilter); + + final String fname = name; + + // create the tabitem + final TabItem newTabItem = createTab(newFilter, -1, true); + + // if the name is unknown + if (fname == null) { + // we need to find the process running under that pid. + // launch a thread do a ps on the device + new Thread("remote PS") { //$NON-NLS-1$ + @Override + public void run() { + // create the receiver + PsOutputReceiver psor = new PsOutputReceiver(md.pid, + newFilter, newTabItem); + + // execute ps + try { + mCurrentLoggedDevice.executeShellCommand("ps", psor); //$NON-NLS-1$ + } catch (IOException e) { + // Ignore + } catch (TimeoutException e) { + // Ignore + } catch (AdbCommandRejectedException e) { + // Ignore + } catch (ShellCommandUnresponsiveException e) { + // Ignore + } + } + }.start(); + } + + return false; + } + + /** + * Adds a new filter to the current filter array, and set its colors + * @param newFilter The filter to add + */ + private void addFilterToArray(LogFilter newFilter) { + // set the colors + newFilter.setColors(mColors); + + // add it to the array. + if (mFilters != null && mFilters.length > 0) { + LogFilter[] newFilters = new LogFilter[mFilters.length+1]; + System.arraycopy(mFilters, 0, newFilters, 0, mFilters.length); + newFilters[mFilters.length] = newFilter; + mFilters = newFilters; + } else { + mFilters = new LogFilter[1]; + mFilters[0] = newFilter; + } + } + + private void removeFilterFromArray(LogFilter oldFilter) { + // look for the index + int index = -1; + for (int i = 0 ; i < mFilters.length ; i++) { + if (mFilters[i] == oldFilter) { + index = i; + break; + } + } + + if (index != -1) { + LogFilter[] newFilters = new LogFilter[mFilters.length-1]; + System.arraycopy(mFilters, 0, newFilters, 0, index); + System.arraycopy(mFilters, index + 1, newFilters, index, + newFilters.length-index); + mFilters = newFilters; + } + } + + /** + * Initialize the filter with already existing buffer. + * @param filter + */ + private void initFilter(LogFilter filter) { + // is it empty + if (filter.uiReady() == false) { + return; + } + + if (filter == mDefaultFilter) { + initDefaultFilter(); + return; + } + + filter.clear(); + + if (mBufferStart != -1) { + int max = mBufferEnd; + if (mBufferEnd < mBufferStart) { + max += STRING_BUFFER_LENGTH; + } + + for (int i = mBufferStart; i < max; i++) { + int realItemIndex = i % STRING_BUFFER_LENGTH; + + filter.addMessage(mBuffer[realItemIndex], null /* old message */); + } + } + + filter.flush(); + filter.resetTempFilteringStatus(); + } + + /** + * Refill the default filter. Not to be called directly. + * @see initFilter() + */ + private void initDefaultFilter() { + mDefaultFilter.clear(); + + if (mBufferStart != -1) { + int max = mBufferEnd; + if (mBufferEnd < mBufferStart) { + max += STRING_BUFFER_LENGTH; + } + + for (int i = mBufferStart; i < max; i++) { + int realItemIndex = i % STRING_BUFFER_LENGTH; + LogMessage msg = mBuffer[realItemIndex]; + + // first we check that the other filters don't take this message + boolean filtered = false; + for (LogFilter f : mFilters) { + filtered |= f.accept(msg); + } + + if (filtered == false) { + mDefaultFilter.addMessage(msg, null /* old message */); + } + } + } + + mDefaultFilter.flush(); + mDefaultFilter.resetTempFilteringStatus(); + } + + /** + * Reset the filters, to handle change in device in automatic filter mode + */ + private void resetFilters() { + // if we are in automatic mode, then we need to rmove the current + // filter. + if (mFilterMode == FILTER_AUTO_PID || mFilterMode == FILTER_AUTO_TAG) { + mFilters = null; + + // recreate the filters. + createFilters(); + } + } + + + private LogFilter getCurrentFilter() { + int index = mFolders.getSelectionIndex(); + + // if mFilters is null or index is invalid, we return the default + // filter. It doesn't matter if that one is null as well, since we + // would return null anyway. + if (index == 0 || mFilters == null) { + return mDefaultFilter; + } + + return mFilters[index-1]; + } + + + private void emptyTables() { + for (LogFilter f : mFilters) { + f.getTable().removeAll(); + } + + if (mDefaultFilter != null) { + mDefaultFilter.getTable().removeAll(); + } + } + + protected void updateFilteringWith(String text) { + synchronized (mBuffer) { + // reset the temp filtering for all the filters + for (LogFilter f : mFilters) { + f.resetTempFiltering(); + } + if (mDefaultFilter != null) { + mDefaultFilter.resetTempFiltering(); + } + + // now we need to figure out the new temp filtering + // split each word + String[] segments = text.split(" "); //$NON-NLS-1$ + + ArrayList keywords = new ArrayList(segments.length); + + // loop and look for temp id/tag + int tempPid = -1; + String tempTag = null; + for (int i = 0 ; i < segments.length; i++) { + String s = segments[i]; + if (tempPid == -1 && s.startsWith("pid:")) { //$NON-NLS-1$ + // get the pid + String[] seg = s.split(":"); //$NON-NLS-1$ + if (seg.length == 2) { + if (seg[1].matches("^[0-9]*$")) { //$NON-NLS-1$ + tempPid = Integer.valueOf(seg[1]); + } + } + } else if (tempTag == null && s.startsWith("tag:")) { //$NON-NLS-1$ + String seg[] = segments[i].split(":"); //$NON-NLS-1$ + if (seg.length == 2) { + tempTag = seg[1]; + } + } else { + keywords.add(s); + } + } + + // set the temp filtering in the filters + if (tempPid != -1 || tempTag != null || keywords.size() > 0) { + String[] keywordsArray = keywords.toArray( + new String[keywords.size()]); + + for (LogFilter f : mFilters) { + if (tempPid != -1) { + f.setTempPidFiltering(tempPid); + } + if (tempTag != null) { + f.setTempTagFiltering(tempTag); + } + f.setTempKeywordFiltering(keywordsArray); + } + + if (mDefaultFilter != null) { + if (tempPid != -1) { + mDefaultFilter.setTempPidFiltering(tempPid); + } + if (tempTag != null) { + mDefaultFilter.setTempTagFiltering(tempTag); + } + mDefaultFilter.setTempKeywordFiltering(keywordsArray); + + } + } + + initFilter(mCurrentFilter); + } + } + + /** + * Called when the current filter selection changes. + * @param selectedFilter + */ + private void selectionChanged(LogFilter selectedFilter) { + if (mLogLevelActions != null) { + // get the log level + int level = selectedFilter.getLogLevel(); + for (int i = 0 ; i < mLogLevelActions.length; i++) { + ICommonAction a = mLogLevelActions[i]; + if (i == level - 2) { + a.setChecked(true); + } else { + a.setChecked(false); + } + } + } + + if (mDeleteFilterAction != null) { + mDeleteFilterAction.setEnabled(selectedFilter.supportsDelete()); + } + if (mEditFilterAction != null) { + mEditFilterAction.setEnabled(selectedFilter.supportsEdit()); + } + } + + public String getSelectedErrorLineMessage() { + Table table = mCurrentFilter.getTable(); + int[] selection = table.getSelectionIndices(); + + if (selection.length == 1) { + TableItem item = table.getItem(selection[0]); + LogMessage msg = (LogMessage)item.getData(); + if (msg.data.logLevel == LogLevel.ERROR || msg.data.logLevel == LogLevel.WARN) + return msg.msg; + } + return null; + } + + public void setLogCatViewInterface(LogCatViewInterface i) { + mLogCatViewInterface = i; + } +} diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/net/NetworkPanel.java b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/net/NetworkPanel.java new file mode 100644 index 00000000..f8cb7a34 --- /dev/null +++ b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/net/NetworkPanel.java @@ -0,0 +1,1108 @@ +/* + * Copyright (C) 2012 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.ddmuilib.net; + +import com.android.ddmlib.AdbCommandRejectedException; +import com.android.ddmlib.Client; +import com.android.ddmlib.IDevice; +import com.android.ddmlib.MultiLineReceiver; +import com.android.ddmlib.ShellCommandUnresponsiveException; +import com.android.ddmlib.TimeoutException; +import com.android.ddmuilib.DdmUiPreferences; +import com.android.ddmuilib.TableHelper; +import com.android.ddmuilib.TablePanel; + +import org.eclipse.core.runtime.IStatus; +import org.eclipse.core.runtime.Status; +import org.eclipse.jface.dialogs.ErrorDialog; +import org.eclipse.jface.preference.IPreferenceStore; +import org.eclipse.jface.viewers.ILabelProviderListener; +import org.eclipse.jface.viewers.IStructuredContentProvider; +import org.eclipse.jface.viewers.ITableLabelProvider; +import org.eclipse.jface.viewers.TableViewer; +import org.eclipse.jface.viewers.Viewer; +import org.eclipse.swt.SWT; +import org.eclipse.swt.events.SelectionAdapter; +import org.eclipse.swt.events.SelectionEvent; +import org.eclipse.swt.graphics.GC; +import org.eclipse.swt.graphics.Image; +import org.eclipse.swt.layout.FormAttachment; +import org.eclipse.swt.layout.FormData; +import org.eclipse.swt.layout.FormLayout; +import org.eclipse.swt.layout.RowLayout; +import org.eclipse.swt.widgets.Button; +import org.eclipse.swt.widgets.Combo; +import org.eclipse.swt.widgets.Composite; +import org.eclipse.swt.widgets.Control; +import org.eclipse.swt.widgets.Display; +import org.eclipse.swt.widgets.Label; +import org.eclipse.swt.widgets.Table; +import org.jfree.chart.ChartFactory; +import org.jfree.chart.JFreeChart; +import org.jfree.chart.axis.AxisLocation; +import org.jfree.chart.axis.NumberAxis; +import org.jfree.chart.axis.ValueAxis; +import org.jfree.chart.plot.DatasetRenderingOrder; +import org.jfree.chart.plot.ValueMarker; +import org.jfree.chart.plot.XYPlot; +import org.jfree.chart.renderer.xy.StackedXYAreaRenderer2; +import org.jfree.chart.renderer.xy.XYAreaRenderer; +import org.jfree.data.DefaultKeyedValues2D; +import org.jfree.data.time.Millisecond; +import org.jfree.data.time.TimePeriod; +import org.jfree.data.time.TimeSeries; +import org.jfree.data.time.TimeSeriesCollection; +import org.jfree.data.xy.AbstractIntervalXYDataset; +import org.jfree.data.xy.TableXYDataset; +import org.jfree.experimental.chart.swt.ChartComposite; +import org.jfree.ui.RectangleAnchor; +import org.jfree.ui.TextAnchor; + +import java.io.IOException; +import java.text.DecimalFormat; +import java.text.FieldPosition; +import java.text.NumberFormat; +import java.text.ParsePosition; +import java.util.ArrayList; +import java.util.Date; +import java.util.Formatter; +import java.util.Iterator; + +/** + * Displays live network statistics for currently selected {@link Client}. + */ +public class NetworkPanel extends TablePanel { + + // TODO: enable view of packets and bytes/packet + // TODO: add sash to resize chart and table + // TODO: let user edit tags to be meaningful + + /** Amount of historical data to display. */ + private static final long HISTORY_MILLIS = 30 * 1000; + + private final static String PREFS_NETWORK_COL_TITLE = "networkPanel.title"; + private final static String PREFS_NETWORK_COL_RX_BYTES = "networkPanel.rxBytes"; + private final static String PREFS_NETWORK_COL_RX_PACKETS = "networkPanel.rxPackets"; + private final static String PREFS_NETWORK_COL_TX_BYTES = "networkPanel.txBytes"; + private final static String PREFS_NETWORK_COL_TX_PACKETS = "networkPanel.txPackets"; + + /** Path to network statistics on remote device. */ + private static final String PROC_XT_QTAGUID = "/proc/net/xt_qtaguid/stats"; + + private static final java.awt.Color TOTAL_COLOR = java.awt.Color.GRAY; + + /** Colors used for tag series data. */ + private static final java.awt.Color[] SERIES_COLORS = new java.awt.Color[] { + java.awt.Color.decode("0x2bc4c1"), // teal + java.awt.Color.decode("0xD50F25"), // red + java.awt.Color.decode("0x3369E8"), // blue + java.awt.Color.decode("0xEEB211"), // orange + java.awt.Color.decode("0x00bd2e"), // green + java.awt.Color.decode("0xae26ae"), // purple + }; + + private Display mDisplay; + + private Composite mPanel; + + /** Header panel with configuration options. */ + private Composite mHeader; + + private Label mSpeedLabel; + private Combo mSpeedCombo; + + /** Current sleep between each sample, from {@link #mSpeedCombo}. */ + private long mSpeedMillis; + + private Button mRunningButton; + private Button mResetButton; + + /** Chart of recent network activity. */ + private JFreeChart mChart; + private ChartComposite mChartComposite; + + private ValueAxis mDomainAxis; + + /** Data for total traffic (tag 0x0). */ + private TimeSeriesCollection mTotalCollection; + private TimeSeries mRxTotalSeries; + private TimeSeries mTxTotalSeries; + + /** Data for detailed tagged traffic. */ + private LiveTimeTableXYDataset mRxDetailDataset; + private LiveTimeTableXYDataset mTxDetailDataset; + + private XYAreaRenderer mTotalRenderer; + private StackedXYAreaRenderer2 mRenderer; + + /** Table showing summary of network activity. */ + private Table mTable; + private TableViewer mTableViewer; + + /** UID of currently selected {@link Client}. */ + private int mActiveUid = -1; + + /** List of traffic flows being actively tracked. */ + private ArrayList mTrackedItems = new ArrayList(); + + private SampleThread mSampleThread; + + private class SampleThread extends Thread { + private volatile boolean mFinish; + + public void finish() { + mFinish = true; + interrupt(); + } + + @Override + public void run() { + while (!mFinish && !mDisplay.isDisposed()) { + performSample(); + + try { + Thread.sleep(mSpeedMillis); + } catch (InterruptedException e) { + // ignored + } + } + } + } + + /** Last snapshot taken by {@link #performSample()}. */ + private NetworkSnapshot mLastSnapshot; + + @Override + protected Control createControl(Composite parent) { + mDisplay = parent.getDisplay(); + + mPanel = new Composite(parent, SWT.NONE); + + final FormLayout formLayout = new FormLayout(); + mPanel.setLayout(formLayout); + + createHeader(); + createChart(); + createTable(); + + return mPanel; + } + + /** + * Create header panel with configuration options. + */ + private void createHeader() { + + mHeader = new Composite(mPanel, SWT.NONE); + final RowLayout layout = new RowLayout(); + layout.center = true; + mHeader.setLayout(layout); + + mSpeedLabel = new Label(mHeader, SWT.NONE); + mSpeedLabel.setText("Speed:"); + mSpeedCombo = new Combo(mHeader, SWT.PUSH); + mSpeedCombo.add("Fast (100ms)"); + mSpeedCombo.add("Medium (250ms)"); + mSpeedCombo.add("Slow (500ms)"); + mSpeedCombo.addSelectionListener(new SelectionAdapter() { + @Override + public void widgetSelected(SelectionEvent e) { + updateSpeed(); + } + }); + + mSpeedCombo.select(1); + updateSpeed(); + + mRunningButton = new Button(mHeader, SWT.PUSH); + mRunningButton.setText("Start"); + mRunningButton.setEnabled(false); + mRunningButton.addSelectionListener(new SelectionAdapter() { + @Override + public void widgetSelected(SelectionEvent e) { + final boolean alreadyRunning = mSampleThread != null; + updateRunning(!alreadyRunning); + } + }); + + mResetButton = new Button(mHeader, SWT.PUSH); + mResetButton.setText("Reset"); + mResetButton.addSelectionListener(new SelectionAdapter() { + @Override + public void widgetSelected(SelectionEvent e) { + clearTrackedItems(); + } + }); + + final FormData data = new FormData(); + data.top = new FormAttachment(0); + data.left = new FormAttachment(0); + data.right = new FormAttachment(100); + mHeader.setLayoutData(data); + } + + /** + * Create chart of recent network activity. + */ + private void createChart() { + + mChart = ChartFactory.createTimeSeriesChart(null, null, null, null, false, false, false); + + // create backing datasets and series + mRxTotalSeries = new TimeSeries("RX total"); + mTxTotalSeries = new TimeSeries("TX total"); + + mRxTotalSeries.setMaximumItemAge(HISTORY_MILLIS); + mTxTotalSeries.setMaximumItemAge(HISTORY_MILLIS); + + mTotalCollection = new TimeSeriesCollection(); + mTotalCollection.addSeries(mRxTotalSeries); + mTotalCollection.addSeries(mTxTotalSeries); + + mRxDetailDataset = new LiveTimeTableXYDataset(); + mTxDetailDataset = new LiveTimeTableXYDataset(); + + mTotalRenderer = new XYAreaRenderer(XYAreaRenderer.AREA); + mRenderer = new StackedXYAreaRenderer2(); + + final XYPlot xyPlot = mChart.getXYPlot(); + + xyPlot.setDatasetRenderingOrder(DatasetRenderingOrder.FORWARD); + + xyPlot.setDataset(0, mTotalCollection); + xyPlot.setDataset(1, mRxDetailDataset); + xyPlot.setDataset(2, mTxDetailDataset); + xyPlot.setRenderer(0, mTotalRenderer); + xyPlot.setRenderer(1, mRenderer); + xyPlot.setRenderer(2, mRenderer); + + // we control domain axis manually when taking samples + mDomainAxis = xyPlot.getDomainAxis(); + mDomainAxis.setAutoRange(false); + + final NumberAxis axis = new NumberAxis(); + axis.setNumberFormatOverride(new BytesFormat(true)); + axis.setAutoRangeMinimumSize(50); + xyPlot.setRangeAxis(axis); + xyPlot.setRangeAxisLocation(AxisLocation.BOTTOM_OR_RIGHT); + + // draw thick line to separate RX versus TX traffic + xyPlot.addRangeMarker( + new ValueMarker(0, java.awt.Color.BLACK, new java.awt.BasicStroke(2))); + + // label to indicate that positive axis is RX traffic + final ValueMarker rxMarker = new ValueMarker(0); + rxMarker.setStroke(new java.awt.BasicStroke(0)); + rxMarker.setLabel("RX"); + rxMarker.setLabelFont(rxMarker.getLabelFont().deriveFont(30f)); + rxMarker.setLabelPaint(java.awt.Color.LIGHT_GRAY); + rxMarker.setLabelAnchor(RectangleAnchor.TOP_RIGHT); + rxMarker.setLabelTextAnchor(TextAnchor.BOTTOM_RIGHT); + xyPlot.addRangeMarker(rxMarker); + + // label to indicate that negative axis is TX traffic + final ValueMarker txMarker = new ValueMarker(0); + txMarker.setStroke(new java.awt.BasicStroke(0)); + txMarker.setLabel("TX"); + txMarker.setLabelFont(txMarker.getLabelFont().deriveFont(30f)); + txMarker.setLabelPaint(java.awt.Color.LIGHT_GRAY); + txMarker.setLabelAnchor(RectangleAnchor.BOTTOM_RIGHT); + txMarker.setLabelTextAnchor(TextAnchor.TOP_RIGHT); + xyPlot.addRangeMarker(txMarker); + + mChartComposite = new ChartComposite(mPanel, SWT.BORDER, mChart, + ChartComposite.DEFAULT_WIDTH, ChartComposite.DEFAULT_HEIGHT, + ChartComposite.DEFAULT_MINIMUM_DRAW_WIDTH, + ChartComposite.DEFAULT_MINIMUM_DRAW_HEIGHT, 4096, 4096, true, true, true, true, + false, true); + + final FormData data = new FormData(); + data.top = new FormAttachment(mHeader); + data.left = new FormAttachment(0); + data.bottom = new FormAttachment(70); + data.right = new FormAttachment(100); + mChartComposite.setLayoutData(data); + } + + /** + * Create table showing summary of network activity. + */ + private void createTable() { + mTable = new Table(mPanel, SWT.BORDER | SWT.MULTI | SWT.FULL_SELECTION); + + final FormData data = new FormData(); + data.top = new FormAttachment(mChartComposite); + data.left = new FormAttachment(mChartComposite, 0, SWT.CENTER); + data.bottom = new FormAttachment(100); + mTable.setLayoutData(data); + + mTable.setHeaderVisible(true); + mTable.setLinesVisible(true); + + final IPreferenceStore store = DdmUiPreferences.getStore(); + + TableHelper.createTableColumn(mTable, "", SWT.CENTER, buildSampleText(2), null, null); + TableHelper.createTableColumn( + mTable, "Tag", SWT.LEFT, buildSampleText(32), PREFS_NETWORK_COL_TITLE, store); + TableHelper.createTableColumn(mTable, "RX bytes", SWT.RIGHT, buildSampleText(12), + PREFS_NETWORK_COL_RX_BYTES, store); + TableHelper.createTableColumn(mTable, "RX packets", SWT.RIGHT, buildSampleText(12), + PREFS_NETWORK_COL_RX_PACKETS, store); + TableHelper.createTableColumn(mTable, "TX bytes", SWT.RIGHT, buildSampleText(12), + PREFS_NETWORK_COL_TX_BYTES, store); + TableHelper.createTableColumn(mTable, "TX packets", SWT.RIGHT, buildSampleText(12), + PREFS_NETWORK_COL_TX_PACKETS, store); + + mTableViewer = new TableViewer(mTable); + mTableViewer.setContentProvider(new ContentProvider()); + mTableViewer.setLabelProvider(new LabelProvider()); + } + + /** + * Update {@link #mSpeedMillis} to match {@link #mSpeedCombo} selection. + */ + private void updateSpeed() { + switch (mSpeedCombo.getSelectionIndex()) { + case 0: + mSpeedMillis = 100; + break; + case 1: + mSpeedMillis = 250; + break; + case 2: + mSpeedMillis = 500; + break; + } + } + + /** + * Update if {@link SampleThread} should be actively running. Will create + * new thread or finish existing thread to match requested state. + */ + private void updateRunning(boolean shouldRun) { + final boolean alreadyRunning = mSampleThread != null; + if (alreadyRunning && !shouldRun) { + mSampleThread.finish(); + mSampleThread = null; + + mRunningButton.setText("Start"); + mHeader.pack(); + } else if (!alreadyRunning && shouldRun) { + mSampleThread = new SampleThread(); + mSampleThread.start(); + + mRunningButton.setText("Stop"); + mHeader.pack(); + } + } + + @Override + public void setFocus() { + mPanel.setFocus(); + } + + private static java.awt.Color nextSeriesColor(int index) { + return SERIES_COLORS[index % SERIES_COLORS.length]; + } + + /** + * Find a {@link TrackedItem} that matches the requested UID and tag, or + * create one if none exists. + */ + public TrackedItem findOrCreateTrackedItem(int uid, int tag) { + // try searching for existing item + for (TrackedItem item : mTrackedItems) { + if (item.uid == uid && item.tag == tag) { + return item; + } + } + + // nothing found; create new item + final TrackedItem item = new TrackedItem(uid, tag); + if (item.isTotal()) { + item.color = TOTAL_COLOR; + item.label = "Total"; + } else { + final int size = mTrackedItems.size(); + item.color = nextSeriesColor(size); + item.label = "0x" + new Formatter().format("%08x", tag); + } + + // create color chip to display as legend in table + item.colorImage = new Image(mDisplay, 20, 20); + final GC gc = new GC(item.colorImage); + gc.setBackground(new org.eclipse.swt.graphics.Color(mDisplay, item.color + .getRed(), item.color.getGreen(), item.color.getBlue())); + gc.fillRectangle(item.colorImage.getBounds()); + gc.dispose(); + + mTrackedItems.add(item); + return item; + } + + /** + * Clear all {@link TrackedItem} and chart history. + */ + public void clearTrackedItems() { + mRxTotalSeries.clear(); + mTxTotalSeries.clear(); + + mRxDetailDataset.clear(); + mTxDetailDataset.clear(); + + mTrackedItems.clear(); + mTableViewer.setInput(mTrackedItems); + } + + /** + * Update the {@link #mRenderer} colors to match {@link TrackedItem#color}. + */ + private void updateSeriesPaint() { + for (TrackedItem item : mTrackedItems) { + final int seriesIndex = mRxDetailDataset.getColumnIndex(item.label); + if (seriesIndex >= 0) { + mRenderer.setSeriesPaint(seriesIndex, item.color); + mRenderer.setSeriesFillPaint(seriesIndex, item.color); + } + } + + // series data is always the same color + final int count = mTotalCollection.getSeriesCount(); + for (int i = 0; i < count; i++) { + mTotalRenderer.setSeriesPaint(i, TOTAL_COLOR); + mTotalRenderer.setSeriesFillPaint(i, TOTAL_COLOR); + } + } + + /** + * Traffic flow being actively tracked, uniquely defined by UID and tag. Can + * record {@link NetworkSnapshot} deltas into {@link TimeSeries} for + * charting, and into summary statistics for {@link Table} display. + */ + private class TrackedItem { + public final int uid; + public final int tag; + + public java.awt.Color color; + public Image colorImage; + + public String label; + public long rxBytes; + public long rxPackets; + public long txBytes; + public long txPackets; + + public TrackedItem(int uid, int tag) { + this.uid = uid; + this.tag = tag; + } + + public boolean isTotal() { + return tag == 0x0; + } + + /** + * Record the given {@link NetworkSnapshot} delta, updating + * {@link TimeSeries} and summary statistics. + * + * @param time Timestamp when delta was observed. + * @param deltaMillis Time duration covered by delta, in milliseconds. + */ + public void recordDelta(Millisecond time, long deltaMillis, NetworkSnapshot.Entry delta) { + final long rxBytesPerSecond = (delta.rxBytes * 1000) / deltaMillis; + final long txBytesPerSecond = (delta.txBytes * 1000) / deltaMillis; + + // record values under correct series + if (isTotal()) { + mRxTotalSeries.addOrUpdate(time, rxBytesPerSecond); + mTxTotalSeries.addOrUpdate(time, -txBytesPerSecond); + } else { + mRxDetailDataset.addValue(rxBytesPerSecond, time, label); + mTxDetailDataset.addValue(-txBytesPerSecond, time, label); + } + + rxBytes += delta.rxBytes; + rxPackets += delta.rxPackets; + txBytes += delta.txBytes; + txPackets += delta.txPackets; + } + } + + @Override + public void deviceSelected() { + // treat as client selection to update enabled states + clientSelected(); + } + + @Override + public void clientSelected() { + mActiveUid = -1; + + final Client client = getCurrentClient(); + if (client != null) { + final int pid = client.getClientData().getPid(); + try { + // map PID to UID from device + final UidParser uidParser = new UidParser(); + getCurrentDevice().executeShellCommand("cat /proc/" + pid + "/status", uidParser); + mActiveUid = uidParser.uid; + } catch (TimeoutException e) { + e.printStackTrace(); + } catch (AdbCommandRejectedException e) { + e.printStackTrace(); + } catch (ShellCommandUnresponsiveException e) { + e.printStackTrace(); + } catch (IOException e) { + e.printStackTrace(); + } + } + + clearTrackedItems(); + updateRunning(false); + + final boolean validUid = mActiveUid != -1; + mRunningButton.setEnabled(validUid); + } + + @Override + public void clientChanged(Client client, int changeMask) { + // ignored + } + + /** + * Take a snapshot from {@link #getCurrentDevice()}, recording any delta + * network traffic to {@link TrackedItem}. + */ + public void performSample() { + final IDevice device = getCurrentDevice(); + if (device == null) return; + + try { + final NetworkSnapshotParser parser = new NetworkSnapshotParser(); + device.executeShellCommand("cat " + PROC_XT_QTAGUID, parser); + + if (parser.isError()) { + mDisplay.asyncExec(new Runnable() { + @Override + public void run() { + updateRunning(false); + + final String title = "Problem reading stats"; + final String message = "Problem reading xt_qtaguid network " + + "statistics from selected device."; + Status status = new Status(IStatus.ERROR, "NetworkPanel", 0, message, null); + ErrorDialog.openError(mPanel.getShell(), title, title, status); + } + }); + + return; + } + + final NetworkSnapshot snapshot = parser.getParsedSnapshot(); + + // use first snapshot as baseline + if (mLastSnapshot == null) { + mLastSnapshot = snapshot; + return; + } + + final NetworkSnapshot delta = NetworkSnapshot.subtract(snapshot, mLastSnapshot); + mLastSnapshot = snapshot; + + // perform delta updates over on UI thread + if (!mDisplay.isDisposed()) { + mDisplay.syncExec(new UpdateDeltaRunnable(delta, snapshot.timestamp)); + } + + } catch (TimeoutException e) { + e.printStackTrace(); + } catch (AdbCommandRejectedException e) { + e.printStackTrace(); + } catch (ShellCommandUnresponsiveException e) { + e.printStackTrace(); + } catch (IOException e) { + e.printStackTrace(); + } + } + + /** + * Task that updates UI with given {@link NetworkSnapshot} delta. + */ + private class UpdateDeltaRunnable implements Runnable { + private final NetworkSnapshot mDelta; + private final long mEndTime; + + public UpdateDeltaRunnable(NetworkSnapshot delta, long endTime) { + mDelta = delta; + mEndTime = endTime; + } + + @Override + public void run() { + if (mDisplay.isDisposed()) return; + + final Millisecond time = new Millisecond(new Date(mEndTime)); + for (NetworkSnapshot.Entry entry : mDelta) { + if (mActiveUid != entry.uid) continue; + + final TrackedItem item = findOrCreateTrackedItem(entry.uid, entry.tag); + item.recordDelta(time, mDelta.timestamp, entry); + } + + // remove any historical detail data + final long beforeMillis = mEndTime - HISTORY_MILLIS; + mRxDetailDataset.removeBefore(beforeMillis); + mTxDetailDataset.removeBefore(beforeMillis); + + // trigger refresh from bulk changes above + mRxDetailDataset.fireDatasetChanged(); + mTxDetailDataset.fireDatasetChanged(); + + // update axis to show latest 30 second time period + mDomainAxis.setRange(mEndTime - HISTORY_MILLIS, mEndTime); + + updateSeriesPaint(); + + // kick table viewer to update + mTableViewer.setInput(mTrackedItems); + } + } + + /** + * Parser that extracts UID from remote {@code /proc/pid/status} file. + */ + private static class UidParser extends MultiLineReceiver { + public int uid = -1; + + @Override + public boolean isCancelled() { + return false; + } + + @Override + public void processNewLines(String[] lines) { + for (String line : lines) { + if (line.startsWith("Uid:")) { + // we care about the "real" UID + final String[] cols = line.split("\t"); + uid = Integer.parseInt(cols[1]); + } + } + } + } + + /** + * Parser that populates {@link NetworkSnapshot} based on contents of remote + * {@link NetworkPanel#PROC_XT_QTAGUID} file. + */ + private static class NetworkSnapshotParser extends MultiLineReceiver { + private NetworkSnapshot mSnapshot; + + public NetworkSnapshotParser() { + mSnapshot = new NetworkSnapshot(System.currentTimeMillis()); + } + + public boolean isError() { + return mSnapshot == null; + } + + public NetworkSnapshot getParsedSnapshot() { + return mSnapshot; + } + + @Override + public boolean isCancelled() { + return false; + } + + @Override + public void processNewLines(String[] lines) { + for (String line : lines) { + if (line.endsWith("No such file or directory")) { + mSnapshot = null; + return; + } + + // ignore header line + if (line.startsWith("idx")) { + continue; + } + + final String[] cols = line.split(" "); + if (cols.length < 9) continue; + + // iface and set are currently ignored, which groups those + // entries together. + final NetworkSnapshot.Entry entry = new NetworkSnapshot.Entry(); + entry.iface = null; //cols[1]; + entry.uid = Integer.parseInt(cols[3]); + entry.set = -1; //Integer.parseInt(cols[4]); + entry.tag = (int) (Long.decode(cols[2]) >> 32); + entry.rxBytes = Long.parseLong(cols[5]); + entry.rxPackets = Long.parseLong(cols[6]); + entry.txBytes = Long.parseLong(cols[7]); + entry.txPackets = Long.parseLong(cols[8]); + + mSnapshot.combine(entry); + } + } + } + + /** + * Parsed snapshot of {@link NetworkPanel#PROC_XT_QTAGUID} at specific time. + */ + private static class NetworkSnapshot implements Iterable { + private ArrayList mStats = new ArrayList(); + + public final long timestamp; + + /** Single parsed statistics row. */ + public static class Entry { + public String iface; + public int uid; + public int set; + public int tag; + public long rxBytes; + public long rxPackets; + public long txBytes; + public long txPackets; + + public boolean isEmpty() { + return rxBytes == 0 && rxPackets == 0 && txBytes == 0 && txPackets == 0; + } + } + + public NetworkSnapshot(long timestamp) { + this.timestamp = timestamp; + } + + public void clear() { + mStats.clear(); + } + + /** + * Combine the given {@link Entry} with any existing {@link Entry}, or + * insert if none exists. + */ + public void combine(Entry entry) { + final Entry existing = findEntry(entry.iface, entry.uid, entry.set, entry.tag); + if (existing != null) { + existing.rxBytes += entry.rxBytes; + existing.rxPackets += entry.rxPackets; + existing.txBytes += entry.txBytes; + existing.txPackets += entry.txPackets; + } else { + mStats.add(entry); + } + } + + @Override + public Iterator iterator() { + return mStats.iterator(); + } + + public Entry findEntry(String iface, int uid, int set, int tag) { + for (Entry entry : mStats) { + if (entry.uid == uid && entry.set == set && entry.tag == tag + && equal(entry.iface, iface)) { + return entry; + } + } + return null; + } + + /** + * Subtract the two given {@link NetworkSnapshot} objects, returning the + * delta between them. + */ + public static NetworkSnapshot subtract(NetworkSnapshot left, NetworkSnapshot right) { + final NetworkSnapshot result = new NetworkSnapshot(left.timestamp - right.timestamp); + + // for each row on left, subtract value from right side + for (Entry leftEntry : left) { + final Entry rightEntry = right.findEntry( + leftEntry.iface, leftEntry.uid, leftEntry.set, leftEntry.tag); + if (rightEntry == null) continue; + + final Entry resultEntry = new Entry(); + resultEntry.iface = leftEntry.iface; + resultEntry.uid = leftEntry.uid; + resultEntry.set = leftEntry.set; + resultEntry.tag = leftEntry.tag; + resultEntry.rxBytes = leftEntry.rxBytes - rightEntry.rxBytes; + resultEntry.rxPackets = leftEntry.rxPackets - rightEntry.rxPackets; + resultEntry.txBytes = leftEntry.txBytes - rightEntry.txBytes; + resultEntry.txPackets = leftEntry.txPackets - rightEntry.txPackets; + + result.combine(resultEntry); + } + + return result; + } + } + + /** + * Provider of {@link #mTrackedItems}. + */ + private class ContentProvider implements IStructuredContentProvider { + @Override + public void inputChanged(Viewer viewer, Object oldInput, Object newInput) { + // pass + } + + @Override + public void dispose() { + // pass + } + + @Override + public Object[] getElements(Object inputElement) { + return mTrackedItems.toArray(); + } + } + + /** + * Provider of labels for {@Link TrackedItem} values. + */ + private static class LabelProvider implements ITableLabelProvider { + private final DecimalFormat mFormat = new DecimalFormat("#,###"); + + @Override + public Image getColumnImage(Object element, int columnIndex) { + if (element instanceof TrackedItem) { + final TrackedItem item = (TrackedItem) element; + switch (columnIndex) { + case 0: + return item.colorImage; + } + } + return null; + } + + @Override + public String getColumnText(Object element, int columnIndex) { + if (element instanceof TrackedItem) { + final TrackedItem item = (TrackedItem) element; + switch (columnIndex) { + case 0: + return null; + case 1: + return item.label; + case 2: + return mFormat.format(item.rxBytes); + case 3: + return mFormat.format(item.rxPackets); + case 4: + return mFormat.format(item.txBytes); + case 5: + return mFormat.format(item.txPackets); + } + } + return null; + } + + @Override + public void addListener(ILabelProviderListener listener) { + // pass + } + + @Override + public void dispose() { + // pass + } + + @Override + public boolean isLabelProperty(Object element, String property) { + // pass + return false; + } + + @Override + public void removeListener(ILabelProviderListener listener) { + // pass + } + } + + /** + * Format that displays simplified byte units for when given values are + * large enough. + */ + private static class BytesFormat extends NumberFormat { + private final String[] mUnits; + private final DecimalFormat mFormat = new DecimalFormat("#.#"); + + public BytesFormat(boolean perSecond) { + if (perSecond) { + mUnits = new String[] { "B/s", "KB/s", "MB/s" }; + } else { + mUnits = new String[] { "B", "KB", "MB" }; + } + } + + @Override + public StringBuffer format(long number, StringBuffer toAppendTo, FieldPosition pos) { + double value = Math.abs(number); + + int i = 0; + while (value > 1024 && i < mUnits.length - 1) { + value /= 1024; + i++; + } + + toAppendTo.append(mFormat.format(value)); + toAppendTo.append(mUnits[i]); + + return toAppendTo; + } + + @Override + public StringBuffer format(double number, StringBuffer toAppendTo, FieldPosition pos) { + return format((long) number, toAppendTo, pos); + } + + @Override + public Number parse(String source, ParsePosition parsePosition) { + return null; + } + } + + public static boolean equal(Object a, Object b) { + return a == b || (a != null && a.equals(b)); + } + + /** + * Build stub string of requested length, usually for measurement. + */ + private static String buildSampleText(int length) { + final StringBuilder builder = new StringBuilder(length); + for (int i = 0; i < length; i++) { + builder.append("X"); + } + return builder.toString(); + } + + /** + * Dataset that contains live measurements. Exposes + * {@link #removeBefore(long)} to efficiently remove old data, and enables + * batched {@link #fireDatasetChanged()} events. + */ + public static class LiveTimeTableXYDataset extends AbstractIntervalXYDataset implements + TableXYDataset { + private DefaultKeyedValues2D mValues = new DefaultKeyedValues2D(true); + + /** + * Caller is responsible for triggering {@link #fireDatasetChanged()}. + */ + public void addValue(Number value, TimePeriod rowKey, String columnKey) { + mValues.addValue(value, rowKey, columnKey); + } + + /** + * Caller is responsible for triggering {@link #fireDatasetChanged()}. + */ + public void removeBefore(long beforeMillis) { + while(mValues.getRowCount() > 0) { + final TimePeriod period = (TimePeriod) mValues.getRowKey(0); + if (period.getEnd().getTime() < beforeMillis) { + mValues.removeRow(0); + } else { + break; + } + } + } + + public int getColumnIndex(String key) { + return mValues.getColumnIndex(key); + } + + public void clear() { + mValues.clear(); + fireDatasetChanged(); + } + + @Override + public void fireDatasetChanged() { + super.fireDatasetChanged(); + } + + @Override + public int getItemCount() { + return mValues.getRowCount(); + } + + @Override + public int getItemCount(int series) { + return mValues.getRowCount(); + } + + @Override + public int getSeriesCount() { + return mValues.getColumnCount(); + } + + @Override + public Comparable getSeriesKey(int series) { + return mValues.getColumnKey(series); + } + + @Override + public double getXValue(int series, int item) { + final TimePeriod period = (TimePeriod) mValues.getRowKey(item); + return period.getStart().getTime(); + } + + @Override + public double getStartXValue(int series, int item) { + return getXValue(series, item); + } + + @Override + public double getEndXValue(int series, int item) { + return getXValue(series, item); + } + + @Override + public Number getX(int series, int item) { + return getXValue(series, item); + } + + @Override + public Number getStartX(int series, int item) { + return getXValue(series, item); + } + + @Override + public Number getEndX(int series, int item) { + return getXValue(series, item); + } + + @Override + public Number getY(int series, int item) { + return mValues.getValue(item, series); + } + + @Override + public Number getStartY(int series, int item) { + return getY(series, item); + } + + @Override + public Number getEndY(int series, int item) { + return getY(series, item); + } + } +} diff --git a/android-core/plugins/org.eclipse.andmore/andmore.target b/android-core/plugins/org.eclipse.andmore/andmore.target index 02b41102..3b832d57 100644 --- a/android-core/plugins/org.eclipse.andmore/andmore.target +++ b/android-core/plugins/org.eclipse.andmore/andmore.target @@ -54,6 +54,7 @@ + diff --git a/android-core/pom.xml b/android-core/pom.xml index 48a97cf0..0a6027cb 100644 --- a/android-core/pom.xml +++ b/android-core/pom.xml @@ -21,6 +21,7 @@ plugins/org.eclipse.andmore.base + plugins/org.eclipse.andmore.ddmsuilib plugins/org.eclipse.andmore.ddms plugins/org.eclipse.andmore.gldebugger plugins/org.eclipse.andmore.hierarchyviewer From dd9fe88571744cb94908bb4128d903d7fc27b884 Mon Sep 17 00:00:00 2001 From: David Carver Date: Fri, 29 May 2015 12:36:20 -0400 Subject: [PATCH 2/2] [463143] Continue Migration to SWTChart Bug: https://bugs.eclipse.org/bugs/show_bug.cgi?id=463143 Signed-off-by: David Carver --- .../java/com/android/ddmuilib/HeapPanel.java | 145 +++++----- .../com/android/ddmuilib/SysinfoPanel.java | 92 +++--- .../charts/piechart/IColorsConstants.java | 23 ++ .../andmore/charts/piechart/PieChart.java | 164 +++++++++++ .../piechart/PieChartPaintListener.java | 263 ++++++++++++++++++ 5 files changed, 567 insertions(+), 120 deletions(-) create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/org/eclipse/andmore/charts/piechart/IColorsConstants.java create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/org/eclipse/andmore/charts/piechart/PieChart.java create mode 100644 android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/org/eclipse/andmore/charts/piechart/PieChartPaintListener.java diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/HeapPanel.java b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/HeapPanel.java index d0af8b08..3f8e9892 100644 --- a/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/HeapPanel.java +++ b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/HeapPanel.java @@ -49,20 +49,9 @@ import org.eclipse.swt.widgets.Table; import org.eclipse.swt.widgets.TableColumn; import org.eclipse.swt.widgets.TableItem; -import org.jfree.chart.ChartFactory; -import org.jfree.chart.JFreeChart; -import org.jfree.chart.axis.CategoryAxis; -import org.jfree.chart.axis.CategoryLabelPositions; -import org.jfree.chart.labels.CategoryToolTipGenerator; -import org.jfree.chart.plot.CategoryPlot; -import org.jfree.chart.plot.Plot; -import org.jfree.chart.plot.PlotOrientation; -import org.jfree.chart.renderer.category.CategoryItemRenderer; -import org.jfree.chart.title.TextTitle; -import org.jfree.data.category.CategoryDataset; -import org.jfree.data.category.DefaultCategoryDataset; -import org.jfree.experimental.chart.swt.ChartComposite; -import org.jfree.experimental.swt.SWTUtils; +import org.swtchart.Chart; +import org.swtchart.IBarSeries; +import org.swtchart.ISeries.SeriesType; import java.io.ByteArrayInputStream; import java.io.IOException; @@ -133,10 +122,8 @@ public final class HeapPanel extends BaseHeapPanel { private Composite mStatisticsBase; private Table mStatisticsTable; - private JFreeChart mChart; - private ChartComposite mChartComposite; + private Chart mChart; private Button mGcButton; - private DefaultCategoryDataset mAllocCountDataSet; private Composite mLinearBase; private Label mLinearHeapImage; @@ -370,7 +357,7 @@ public void widgetSelected(SelectionEvent e) { mStatisticsTable = createDetailedTable(mStatisticsBase); mStatisticsTable.setLayoutData(new GridData(GridData.FILL_BOTH)); - createChart(); + createChart(mStatisticsBase); //create the linear composite mLinearBase = new Composite(mDisplayBase, SWT.NONE); @@ -600,63 +587,72 @@ public void widgetSelected(SelectionEvent e) { /** * Creates the chart below the statistics table */ - private void createChart() { - mAllocCountDataSet = new DefaultCategoryDataset(); - mChart = ChartFactory.createBarChart(null, "Size", "Count", mAllocCountDataSet, - PlotOrientation.VERTICAL, false, true, false); + private void createChart(Composite parent) { + mChart = new Chart(parent, SWT.NONE); + +// mAllocCountDataSet = new DefaultCategoryDataset(); +// mChart = ChartFactory.createBarChart(null, "Size", "Count", mAllocCountDataSet, +// PlotOrientation.VERTICAL, false, true, false); // get the font to make a proper title. We need to convert the swt font, // into an awt font. - Font f = mStatisticsBase.getFont(); - FontData[] fData = f.getFontData(); + Font font = mStatisticsBase.getFont(); + + + mChart.getTitle().setText("Allocation count per size"); + mChart.getTitle().setFont(font); + + mChart.getAxisSet().getXAxis(0).getTitle().setText("Size"); + mChart.getAxisSet().getYAxis(0).getTitle().setText("Count"); + + // create bar series +// IBarSeries barSeries = (IBarSeries) chart.getSeriesSet() +// .createSeries(SeriesType.BAR, "bar series"); +// barSeries.setYSeries(); + + +// Plot plot = mChart.getPlot(); +// if (plot instanceof CategoryPlot) { +// // get the plot +// CategoryPlot categoryPlot = (CategoryPlot)plot; +// +// // set the domain axis to draw labels that are displayed even with many values. +// CategoryAxis domainAxis = categoryPlot.getDomainAxis(); +// domainAxis.setCategoryLabelPositions(CategoryLabelPositions.DOWN_90); +// +// CategoryItemRenderer renderer = categoryPlot.getRenderer(); +// renderer.setBaseToolTipGenerator(new CategoryToolTipGenerator() { +// @Override +// public String generateToolTip(CategoryDataset dataset, int row, int column) { +// // get the key for the size of the allocation +// ByteLong columnKey = (ByteLong)dataset.getColumnKey(column); +// String rowKey = (String)dataset.getRowKey(row); +// Number value = dataset.getValue(rowKey, columnKey); +// +// return String.format("%1$d %2$s of %3$d bytes", value.intValue(), rowKey, +// columnKey.getValue()); +// } +// }); +// } +// mChartComposite = new ChartComposite(mStatisticsBase, SWT.BORDER, mChart, +// ChartComposite.DEFAULT_WIDTH, +// ChartComposite.DEFAULT_HEIGHT, +// ChartComposite.DEFAULT_MINIMUM_DRAW_WIDTH, +// ChartComposite.DEFAULT_MINIMUM_DRAW_HEIGHT, +// 3000, // max draw width. We don't want it to zoom, so we put a big number +// 3000, // max draw height. We don't want it to zoom, so we put a big number +// true, // off-screen buffer +// true, // properties +// true, // save +// true, // print +// false, // zoom +// true); // tooltips + + mChart.setLayoutData(new GridData(GridData.FILL_BOTH)); + + // adjust the axis range + mChart.getAxisSet().adjustRange(); - // event though on Mac OS there could be more than one fontData, we'll only use - // the first one. - FontData firstFontData = fData[0]; - - java.awt.Font awtFont = SWTUtils.toAwtFont(mStatisticsBase.getDisplay(), - firstFontData, true /* ensureSameSize */); - - mChart.setTitle(new TextTitle("Allocation count per size", awtFont)); - - Plot plot = mChart.getPlot(); - if (plot instanceof CategoryPlot) { - // get the plot - CategoryPlot categoryPlot = (CategoryPlot)plot; - - // set the domain axis to draw labels that are displayed even with many values. - CategoryAxis domainAxis = categoryPlot.getDomainAxis(); - domainAxis.setCategoryLabelPositions(CategoryLabelPositions.DOWN_90); - - CategoryItemRenderer renderer = categoryPlot.getRenderer(); - renderer.setBaseToolTipGenerator(new CategoryToolTipGenerator() { - @Override - public String generateToolTip(CategoryDataset dataset, int row, int column) { - // get the key for the size of the allocation - ByteLong columnKey = (ByteLong)dataset.getColumnKey(column); - String rowKey = (String)dataset.getRowKey(row); - Number value = dataset.getValue(rowKey, columnKey); - - return String.format("%1$d %2$s of %3$d bytes", value.intValue(), rowKey, - columnKey.getValue()); - } - }); - } - mChartComposite = new ChartComposite(mStatisticsBase, SWT.BORDER, mChart, - ChartComposite.DEFAULT_WIDTH, - ChartComposite.DEFAULT_HEIGHT, - ChartComposite.DEFAULT_MINIMUM_DRAW_WIDTH, - ChartComposite.DEFAULT_MINIMUM_DRAW_HEIGHT, - 3000, // max draw width. We don't want it to zoom, so we put a big number - 3000, // max draw height. We don't want it to zoom, so we put a big number - true, // off-screen buffer - true, // properties - true, // save - true, // print - false, // zoom - true); // tooltips - - mChartComposite.setLayoutData(new GridData(GridData.FILL_BOTH)); } private static String prettyByteCount(long bytes) { @@ -888,7 +884,8 @@ public int compareTo(ByteLong other) { * Fills the chart with the content of the list of {@link HeapSegmentElement}. */ private void showChart(ArrayList list) { - mAllocCountDataSet.clear(); + + if (list != null) { String rowKey = "Alloc Count"; @@ -899,7 +896,7 @@ private void showChart(ArrayList list) { if (element.getLength() != currentSize) { if (currentSize != -1) { ByteLong columnKey = new ByteLong(currentSize); - mAllocCountDataSet.addValue(currentCount, rowKey, columnKey); + // mAllocCountDataSet.addValue(currentCount, rowKey, columnKey); } currentSize = element.getLength(); @@ -912,7 +909,7 @@ private void showChart(ArrayList list) { // add the last item if (currentSize != -1) { ByteLong columnKey = new ByteLong(currentSize); - mAllocCountDataSet.addValue(currentCount, rowKey, columnKey); + // mAllocCountDataSet.addValue(currentCount, rowKey, columnKey); } } } diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/SysinfoPanel.java b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/SysinfoPanel.java index 3ca5ff3b..23ad679c 100644 --- a/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/SysinfoPanel.java +++ b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/com/android/ddmuilib/SysinfoPanel.java @@ -23,6 +23,7 @@ import com.android.ddmlib.ShellCommandUnresponsiveException; import com.android.ddmlib.TimeoutException; +import org.eclipse.andmore.charts.piechart.PieChart; import org.eclipse.swt.SWT; import org.eclipse.swt.events.SelectionAdapter; import org.eclipse.swt.events.SelectionEvent; @@ -35,10 +36,6 @@ import org.eclipse.swt.widgets.Control; import org.eclipse.swt.widgets.FileDialog; import org.eclipse.swt.widgets.Label; -import org.jfree.chart.ChartFactory; -import org.jfree.chart.JFreeChart; -import org.jfree.data.general.DefaultPieDataset; -import org.jfree.experimental.chart.swt.ChartComposite; import java.io.BufferedReader; import java.io.File; @@ -48,6 +45,10 @@ import java.util.regex.Matcher; import java.util.regex.Pattern; +// PieChart implementation using SWTChart +// https://eclipse.googlesource.com/linuxtools/org.eclipse.linuxtools/+/f6cf0d213bc791ca45b2e3b8f0c6c4d01375177f/profiling/org.eclipse.linuxtools.dataviewers.piechart/src/org/eclipse/linuxtools/dataviewers/piechart/PieChart.java + + /** * Displays system information graphs obtained from a bugreport file or device. */ @@ -57,8 +58,7 @@ public class SysinfoPanel extends TablePanel implements IShellOutputReceiver { private Label mLabel; private Button mFetchButton; private Combo mDisplayMode; - - private DefaultPieDataset mDataset; + private PieChart pieChart; // The bugreport file to process private File mDataFile; @@ -92,7 +92,7 @@ public class SysinfoPanel extends TablePanel implements IShellOutputReceiver { * @param file The bugreport file to process. */ public void generateDataset(File file) { - mDataset.clear(); + //mDataset.clear(); mLabel.setText(""); if (file == null) { return; @@ -281,27 +281,27 @@ public void widgetSelected(SelectionEvent e) { mLabel = new Label(top, SWT.NONE); mLabel.setLayoutData(new GridData(GridData.FILL_HORIZONTAL)); - mDataset = new DefaultPieDataset(); - JFreeChart chart = ChartFactory.createPieChart("", mDataset, false - /* legend */, true/* tooltips */, false /* urls */); - - ChartComposite chartComposite = new ChartComposite(top, - SWT.BORDER, chart, - ChartComposite.DEFAULT_HEIGHT, - ChartComposite.DEFAULT_HEIGHT, - ChartComposite.DEFAULT_MINIMUM_DRAW_WIDTH, - ChartComposite.DEFAULT_MINIMUM_DRAW_HEIGHT, - 3000, - // max draw width. We don't want it to zoom, so we put a big number - 3000, - // max draw height. We don't want it to zoom, so we put a big number - true, // off-screen buffer - true, // properties - true, // save - true, // print - false, // zoom - true); - chartComposite.setLayoutData(new GridData(GridData.FILL_BOTH)); +// mDataset = new DefaultPieDataset(); + pieChart = new PieChart(top, SWT.BORDER); + + +// ChartComposite chartComposite = new ChartComposite(top, +// SWT.BORDER, chart, +// ChartComposite.DEFAULT_HEIGHT, +// ChartComposite.DEFAULT_HEIGHT, +// ChartComposite.DEFAULT_MINIMUM_DRAW_WIDTH, +// ChartComposite.DEFAULT_MINIMUM_DRAW_HEIGHT, +// 3000, +// // max draw width. We don't want it to zoom, so we put a big number +// 3000, +// // max draw height. We don't want it to zoom, so we put a big number +// true, // off-screen buffer +// true, // properties +// true, // save +// true, // print +// false, // zoom +// true); + pieChart.setLayoutData(new GridData(GridData.FILL_BOTH)); return top; } @@ -393,7 +393,7 @@ void readWakelockDataset(BufferedReader br) throws IOException { Matcher m = lockPattern.matcher(line); if (m.find()) { double value = parseTimeMs(m.group(2)) / 1000.; - mDataset.setValue(m.group(1), value); +// mDataset.setValue(m.group(1), value); total -= value; } else { m = totalPattern.matcher(line); @@ -404,7 +404,7 @@ void readWakelockDataset(BufferedReader br) throws IOException { } } if (total > 0) { - mDataset.setValue("Unlocked", total); +// mDataset.setValue("Unlocked", total); } } @@ -429,7 +429,7 @@ void readAlarmDataset(BufferedReader br) throws IOException { if (m.find()) { long count = Long.parseLong(m.group(1)); String name = m.group(2); - mDataset.setValue(name, count); +// mDataset.setValue(name, count); } } } @@ -463,20 +463,20 @@ void readCpuDataset(BufferedReader br) throws IOException { long kernel = Long.parseLong(m.group(4)); if ("TOTAL".equals(name)) { if (both < 100) { - mDataset.setValue("Idle", (100 - both)); +// mDataset.setValue("Idle", (100 - both)); } } else { // Try to make graphs more useful even with rounding; // log often has 0% user + 0% kernel = 1% total // We arbitrarily give extra to kernel if (user > 0) { - mDataset.setValue(name + " (user)", user); +// mDataset.setValue(name + " (user)", user); } if (kernel > 0) { - mDataset.setValue(name + " (kernel)" , both - user); +// mDataset.setValue(name + " (kernel)" , both - user); } if (user == 0 && kernel == 0 && both > 0) { - mDataset.setValue(name, both); +// mDataset.setValue(name, both); } } } @@ -509,22 +509,22 @@ void readMeminfoDataset(BufferedReader br) throws IOException { if (line.startsWith("MemTotal")) { total = kb; } else if (line.startsWith("MemFree")) { - mDataset.setValue("Free", kb); +// mDataset.setValue("Free", kb); total -= kb; } else if (line.startsWith("Slab")) { - mDataset.setValue("Slab", kb); +// mDataset.setValue("Slab", kb); total -= kb; } else if (line.startsWith("PageTables")) { - mDataset.setValue("PageTables", kb); +// mDataset.setValue("PageTables", kb); total -= kb; } else if (line.startsWith("Buffers") && kb > 0) { - mDataset.setValue("Buffers", kb); +// mDataset.setValue("Buffers", kb); total -= kb; } else if (line.startsWith("Inactive")) { - mDataset.setValue("Inactive", kb); +// mDataset.setValue("Inactive", kb); total -= kb; } else if (line.startsWith("MemFree")) { - mDataset.setValue("Free", kb); +// mDataset.setValue("Free", kb); total -= kb; } } else { @@ -550,14 +550,14 @@ void readMeminfoDataset(BufferedReader br) throws IOException { String cmdline = line.substring(43).trim().replace("/system/bin/", ""); // Arbitrary minimum size to display if (pss > 2000) { - mDataset.setValue(cmdline, pss); +// mDataset.setValue(cmdline, pss); } else { other += pss; } total -= pss; } - mDataset.setValue("Other", other); - mDataset.setValue("Unknown", total); +// mDataset.setValue("Other", other); +// mDataset.setValue("Unknown", total); } /** @@ -582,12 +582,12 @@ void readSyncDataset(BufferedReader br) throws IOException { if (durParts.length == 2) { long dur = Long.parseLong(durParts[0]) * 60 + Long .parseLong(durParts[1]); - mDataset.setValue(authority, dur); +// mDataset.setValue(authority, dur); } else if (duration.length() == 3) { long dur = Long.parseLong(durParts[0]) * 3600 + Long.parseLong(durParts[1]) * 60 + Long .parseLong(durParts[2]); - mDataset.setValue(authority, dur); +// mDataset.setValue(authority, dur); } } } diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/org/eclipse/andmore/charts/piechart/IColorsConstants.java b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/org/eclipse/andmore/charts/piechart/IColorsConstants.java new file mode 100644 index 00000000..181bbe1d --- /dev/null +++ b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/org/eclipse/andmore/charts/piechart/IColorsConstants.java @@ -0,0 +1,23 @@ +/******************************************************************************* + * Copyright (c) 2012 IBM Corporation. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + * + * Contributors: + * IBM Corporation - Renato Stoffalette Joao + *******************************************************************************/ +package org.eclipse.andmore.charts.piechart; + +import org.eclipse.swt.graphics.RGB; + +public interface IColorsConstants { + final RGB[] COLORS = new RGB[] { new RGB(255, 0, 0), new RGB(0, 255, 0), new RGB(0, 0, 255), + new RGB(255, 255, 0), new RGB(255, 0, 255), new RGB(0, 255, 255), new RGB(255, 255, 255), + new RGB(0, 100, 205), new RGB(0, 150, 100), new RGB(205, 0, 100), new RGB(0, 0, 0), new RGB(100, 255, 255), + new RGB(255, 100, 255) + + }; + +} \ No newline at end of file diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/org/eclipse/andmore/charts/piechart/PieChart.java b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/org/eclipse/andmore/charts/piechart/PieChart.java new file mode 100644 index 00000000..169b8f95 --- /dev/null +++ b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/org/eclipse/andmore/charts/piechart/PieChart.java @@ -0,0 +1,164 @@ +/******************************************************************************* + * Copyright (c) 2012 IBM Corporation. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + * + * Contributors: + * IBM Corporation - Renato Stoffalette Joao + *******************************************************************************/ +package org.eclipse.andmore.charts.piechart; + +import java.util.ArrayList; +import java.util.List; + +import org.eclipse.swt.events.PaintListener; +import org.eclipse.swt.graphics.Color; +import org.eclipse.swt.graphics.RGB; +import org.eclipse.swt.widgets.Composite; +import org.eclipse.swt.widgets.Control; +import org.swtchart.Chart; +import org.swtchart.IAxis; +import org.swtchart.IBarSeries; +import org.swtchart.ISeries; +import org.swtchart.ITitle; + +public class PieChart extends Chart { + + protected List colorList = new ArrayList(); + private Color[] customColors = null; + private PieChartPaintListener pieChartPaintListener; + + public PieChart(Composite parent, int style) { + super(parent, style); + // Hide all original axes and plot area + for (IAxis axis : getAxisSet().getAxes()) { + axis.getTitle().setVisible(false); + } + getPlotArea().setVisible(false); + // Make the title draw after the pie-chart itself so we can modify the title + // to center over the pie-chart area + ITitle title = getTitle(); + // Underlying SWT Chart implementation changes from the title being a Control to just + // a PaintListener. In the Control class case, we can move it's location to + // center over a PieChart, but in the latter case, we need to alter the title + // with blanks in the PieChartPaintListener and have the title paint after it + // once the title has been altered. + if (title instanceof Control) { + addPaintListener(pieChartPaintListener = new PieChartPaintListener(this)); + } else { + removePaintListener((PaintListener)title); + addPaintListener(pieChartPaintListener = new PieChartPaintListener(this)); + addPaintListener((PaintListener)title); + } + IAxis xAxis = getAxisSet().getXAxis(0); + xAxis.enableCategory(true); + xAxis.setCategorySeries(new String[]{""}); //$NON-NLS-1$ + } + + @Override + public void addPaintListener(PaintListener listener) { + if (!listener.getClass().getName().startsWith("org.swtchart.internal.axis")) { //$NON-NLS-1$ + super.addPaintListener(listener); + } + } + + /** + * Sets the custom colors to use. + * @param customColors The custom colors to use. + * @since 2.0 + */ + public void setCustomColors(Color[] customColors) { + this.customColors = customColors.clone(); + } + + /** + * Add data to this Pie Chart. We'll build one pie chart for each value in the array provided. The val matrix must + * have an array of an array of values. Ex. labels = {'a', 'b'} val = {{1,2,3}, {4,5,6}} This will create 3 pie + * charts. For the first one, 'a' will be 1 and 'b' will be 4. For the second chart 'a' will be 2 and 'b' will be 5. + * For the third 'a' will be 3 and 'b' will be 6. + * @param labels The titles of each series. (These are not the same as titles given to pies.) + * @param val New values. + */ + public void addPieChartSeries(String labels[], double val[][]) { + setSeriesNames(val[0].length); + for (ISeries s : this.getSeriesSet().getSeries()) { + this.getSeriesSet().deleteSeries(s.getId()); + } + + int size = Math.min(labels.length, val.length); + for (int i = 0; i < size; i++) { + IBarSeries s = (IBarSeries) this.getSeriesSet().createSeries(ISeries.SeriesType.BAR, labels[i]); + double d[] = new double[val[i].length]; + for (int j = 0; j < val[i].length; j++) { + d[j] = val[i][j]; + } + s.setXSeries(d); + if (customColors != null) { + s.setBarColor(customColors[i % customColors.length]); + } else { + s.setBarColor(new Color(this.getDisplay(), sliceColor(i))); + } + } + } + + /** + * Sets this chart's category names such that the number of names + * is equal to the number of pies. This method will only make changes + * to category series if they are not already properly set. + * @param numExpected The number of pies / the expected number of category names. + */ + private void setSeriesNames(int numExpected) { + IAxis xAxis = getAxisSet().getXAxis(0); + if (xAxis.getCategorySeries().length != numExpected) { + String[] seriesNames = new String[numExpected]; + for (int i = 0, n = Math.min(xAxis.getCategorySeries().length, numExpected); i < n; i++) { + seriesNames[i] = xAxis.getCategorySeries()[i]; + } + for (int i = xAxis.getCategorySeries().length; i < numExpected; i++) { + seriesNames[i] = ""; //$NON-NLS-1$ + } + xAxis.setCategorySeries(seriesNames); + } + } + + protected RGB sliceColor(int i) { + if (colorList.size() > i) { + return colorList.get(i); + } + + RGB next = IColorsConstants.COLORS[i % IColorsConstants.COLORS.length]; + colorList.add(next); + return next; + } + + /** + * Given a set of 2D pixel coordinates (typically those of a mouse cursor), return the + * index of the given pie's slice that those coordinates reside in. + * @param pieIndex The index of the pie to get the slice of. + * @param x The x-coordinate to test. + * @param y The y-coordinate to test. + * @return The slice that contains the point with coordinates (x,y). + * @since 2.0 + */ + public int getSliceIndexFromPosition(int pieIndex, int x, int y) { + return pieChartPaintListener.getSliceIndexFromPosition(pieIndex, x, y); + } + + /** + * Given a pie and one of its slices, returns the size of the slice as a percentage of the pie. + * @param pieIndex The index of the pie to check. + * @param sliceIndex The slice of the pie to get the percentage of. + * @return The percentage of the entire pie taken up by the slice. + * @since 2.0 + */ + public double getSlicePercent(int pieIndex, int sliceIndex) { + double max = 0; + ISeries series[] = getSeriesSet().getSeries(); + for (int i = 0; i < series.length; i++) { + max += series[i].getXSeries()[pieIndex]; + } + return series[sliceIndex].getXSeries()[pieIndex] / max * 100; + } +} \ No newline at end of file diff --git a/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/org/eclipse/andmore/charts/piechart/PieChartPaintListener.java b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/org/eclipse/andmore/charts/piechart/PieChartPaintListener.java new file mode 100644 index 00000000..0f24b89e --- /dev/null +++ b/android-core/plugins/org.eclipse.andmore.ddmsuilib/src/main/java/org/eclipse/andmore/charts/piechart/PieChartPaintListener.java @@ -0,0 +1,263 @@ +/******************************************************************************* + * Copyright (c) 2012 IBM Corporation. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + * + * Contributors: + * IBM Corporation - Renato Stoffalette Joao + *******************************************************************************/package org.eclipse.andmore.charts.piechart; + +import org.eclipse.swt.SWT; +import org.eclipse.swt.events.PaintEvent; +import org.eclipse.swt.events.PaintListener; +import org.eclipse.swt.graphics.Color; +import org.eclipse.swt.graphics.Font; +import org.eclipse.swt.graphics.GC; +import org.eclipse.swt.graphics.Point; +import org.eclipse.swt.graphics.Rectangle; +import org.eclipse.swt.widgets.Control; +import org.eclipse.swt.widgets.Display; +import org.swtchart.IBarSeries; +import org.swtchart.ISeries; +import org.swtchart.ITitle; +import org.swtchart.Range; + +public class PieChartPaintListener implements PaintListener { + + private PieChart chart; + private Control plotArea; + private double[][] seriesValues; + private String[] seriesNames; + private static final int X_GAP = 10; + + private static final Color WHITE = Display.getDefault().getSystemColor(SWT.COLOR_WHITE); + private static final Color BLACK = Display.getDefault().getSystemColor(SWT.COLOR_BLACK); + private static final String FONT = "Arial"; //$NON-NLS-1$ + + private Point[] pieCenters; + private int[][] pieSliceAngles; + private int pieWidth; + + private String origTitleText; + + /** + * Handles drawing and updating of a PieChart, with titles given to its legend and + * to each of its pies. Pies will be drawn in the given chart's plot area. + * @param chart The PieChart to draw and update. + * @since 2.0 + */ + public PieChartPaintListener(PieChart chart) { + this.chart = chart; + this.plotArea = chart.getPlotArea(); + } + + @Override + public void paintControl(PaintEvent e) { + GC gc = e.gc; + Rectangle bounds; + this.getPieSeriesArray(); + pieCenters = new Point[seriesValues.length]; + pieSliceAngles = new int[seriesValues.length][]; + if (seriesValues.length == 0) { + bounds = gc.getClipping(); + Font oldFont = gc.getFont(); + Font font = new Font(Display.getDefault(), FONT, 15, SWT.BOLD); + gc.setForeground(BLACK); + gc.setFont(font); + String text = "No data"; //$NON-NLS-1$ + Point textSize = e.gc.textExtent(text); + gc.drawText(text, (bounds.width - textSize.x) / 2, (bounds.height - textSize.y) / 2); + gc.setFont(oldFont); + font.dispose(); + return; + } + bounds = plotArea.getBounds(); + // Adjust the title so it centers in the plot area + if (origTitleText == null) { + origTitleText = chart.getTitle().getText(); + } + + // We want to center the title in the plot area rather than the entire view which includes + // the legend. To force this, we have two algorithms depending on what level of the + // underlying SWT Chart software we are using. If the title is an SWT Control, we simply + // set the title's location manually. If the title is just a PaintListener, we center it + // by adding a number of trailing spaces which we calculate. + if (chart.getTitle() instanceof Control) { + setTitleBounds(bounds); + } else { + adjustTitle(e); + } + int width = bounds.width / seriesValues.length; + int x = bounds.x; + + if (chart.getLegend().isVisible()) { + Rectangle legendBounds = ((Control) chart.getLegend()).getBounds(); + Font oldFont = gc.getFont(); + Font font = new Font(Display.getDefault(), FONT, 10, SWT.BOLD); + gc.setForeground(BLACK); + gc.setFont(font); + String text = chart.getAxisSet().getXAxis(0).getTitle().getText(); + Point textSize = e.gc.textExtent(text); + gc.drawText(text, legendBounds.x + (legendBounds.width - textSize.x) / 2, legendBounds.y - textSize.y); + gc.setFont(oldFont); + font.dispose(); + } + + pieWidth = Math.min(width - X_GAP, bounds.height); + for (int i = 0; i < seriesValues.length; i++) { + drawPieChart(e, i, new Rectangle(x, bounds.y, width, bounds.height)); + x += width; + } + } + + // For a title which is a Control, position it appropriately to center in the plot area. + private void setTitleBounds(Rectangle bounds) { + Control title = (Control) chart.getTitle(); + Rectangle titleBounds = title.getBounds(); + title.setLocation(new Point(bounds.x + (bounds.width - titleBounds.width) / 2, title.getLocation().y)); + } + + // Adjust the title with trailing blanks so it centers in the plot area + // rather than for the entire chart view which looks odd when not + // centered above the pie-charts themselves. + private void adjustTitle(PaintEvent pe) { + ITitle title = chart.getTitle(); + Font font = title.getFont(); + Font oldFont = pe.gc.getFont(); + pe.gc.setFont(font); + Control legend = (Control)chart.getLegend(); + Rectangle legendBounds = legend.getBounds(); + int adjustment = legendBounds.width - 15; + Point blankSize = pe.gc.textExtent(" "); //$NON-NLS-1$ + int numBlanks = ((adjustment / blankSize.x) >> 1) << 1; + String text = origTitleText; + for (int i = 0; i < numBlanks; ++i) + text += " "; //$NON-NLS-1$ + pe.gc.setFont(oldFont); + title.setText(text); + } + + private void drawPieChart(PaintEvent e, int chartnum, Rectangle bounds) { + double series[] = seriesValues[chartnum]; + int nelemSeries = series.length; + double sumTotal = 0; + + pieSliceAngles[chartnum] = new int[nelemSeries - 1]; // Don't need first angle; it's always 0 + for (int i = 0; i < nelemSeries; i++) { + sumTotal += series[i]; + } + + GC gc = e.gc; + Font oldFont = gc.getFont(); + gc.setLineWidth(1); + + int pieX = bounds.x + (bounds.width - pieWidth) / 2; + int pieY = bounds.y + (bounds.height - pieWidth) / 2; + pieCenters[chartnum] = new Point(pieX + pieWidth / 2, pieY + pieWidth / 2); + if (sumTotal == 0) { + gc.drawOval(pieX, pieY, pieWidth, pieWidth); + } else { + double factor = 100 / sumTotal; + int sweepAngle = 0; + int incrementAngle = 0; + int initialAngle = 90; + for (int i = 0; i < nelemSeries; i++) { + // Stored angles increase in clockwise direction from 0 degrees at 12:00 + if (i > 0) { + pieSliceAngles[chartnum][i - 1] = 90 - initialAngle; + } + + gc.setBackground(((IBarSeries) chart.getSeriesSet().getSeries()[i]).getBarColor()); + + if (i == (nelemSeries - 1)) { + sweepAngle = 360 - incrementAngle; + } else { + double angle = series[i] * factor * 3.6; + sweepAngle = (int) Math.round(angle); + } + gc.fillArc(pieX, pieY, pieWidth, pieWidth, initialAngle, (-sweepAngle)); + gc.drawArc(pieX, pieY, pieWidth, pieWidth, initialAngle, (-sweepAngle)); + incrementAngle += sweepAngle; + initialAngle += (-sweepAngle); + } + gc.drawLine(pieCenters[chartnum].x, pieCenters[chartnum].y, pieCenters[chartnum].x, pieCenters[chartnum].y - pieWidth / 2); + } + + Font font = new Font(Display.getDefault(), FONT, 12, SWT.BOLD); + gc.setForeground(BLACK); + gc.setBackground(WHITE); + gc.setFont(font); + String text = seriesNames[chartnum]; + Point textSize = e.gc.textExtent(text); + gc.drawText(text, pieX + (pieWidth - textSize.x) / 2, pieY + pieWidth + textSize.y); + gc.setFont(oldFont); + font.dispose(); + } + + private void getPieSeriesArray() { + ISeries series[] = this.chart.getSeriesSet().getSeries(); + if (series == null || series.length == 0) { + seriesValues = new double[0][0]; + seriesNames = new String[0]; + return; + } + String names[] = this.chart.getAxisSet().getXAxis(0).getCategorySeries(); + Range range = chart.getAxisSet().getXAxis(0).getRange(); + int itemRange = (int) range.upper - (int) range.lower + 1; + int itemOffset = (int) range.lower; + seriesValues = new double[itemRange][series.length]; + seriesNames = new String[itemRange]; + + for (int i = 0; i < seriesValues.length; i++) { + seriesNames[i] = names[i + itemOffset]; + for (int j = 0; j < seriesValues[i].length; j++) { + double d[] = series[j].getXSeries(); + if (d != null && d.length > 0) { + seriesValues[i][j] = d[i + itemOffset]; + } else { + seriesValues[i][j] = 0; + } + } + } + + return; + } + + /** + * Given a set of 2D pixel coordinates (typically those of a mouse cursor), return the + * index of the given pie's slice that those coordinates reside in. + * @param chartnum The id of the chart. + * @param x The x-coordinate to test. + * @param y The y-coordinate to test. + * @return The slice that contains the point with coordinates (x,y). + * @since 2.0 + */ + public int getSliceIndexFromPosition(int chartnum, int x, int y) { + Range range = chart.getAxisSet().getXAxis(0).getRange(); + chartnum -= (int) range.lower; + if (chartnum >= pieCenters.length || chartnum < 0) { + return -1; + } + // Only continue if the point is inside the pie circle + double rad = Math.sqrt(Math.pow(pieCenters[chartnum].x - x, 2) + Math.pow(pieCenters[chartnum].y - y, 2)); + if (2 * rad > pieWidth) { + return -1; + } + // Angle is relative to 12:00 position, increases clockwise + double angle = Math.acos((pieCenters[chartnum].y - y) / rad) / Math.PI * 180.0; + if (x - pieCenters[chartnum].x < 0) { + angle = 360 - angle; + } + if (pieSliceAngles[chartnum].length == 0 || angle < pieSliceAngles[chartnum][0]) { + return 0; + } + for (int s = 0; s < pieSliceAngles[chartnum].length - 1; s++) { + if (pieSliceAngles[chartnum][s] <= angle && angle < pieSliceAngles[chartnum][s+1]) { + return s + 1; + } + } + return pieSliceAngles[chartnum].length; + } +} \ No newline at end of file