/* Copyright 2016 Software Freedom Conservancy Inc. * * This software is licensed under the GNU LGPL (version 2.1 or later). * See the COPYING file in this distribution. */ private class ImportSourceCollection : SourceCollection { public ImportSourceCollection(string name) { base (name); } public override bool holds_type_of_source(DataSource source) { return source is ImportSource; } } abstract class ImportSource : ThumbnailSource, Indexable { private string camera_name; private GPhoto.Camera camera; private int fsid; private string folder; private string filename; private ulong file_size; private time_t modification_time; private Gdk.Pixbuf? preview = null; private string? indexable_keywords = null; public ImportSource(string camera_name, GPhoto.Camera camera, int fsid, string folder, string filename, ulong file_size, time_t modification_time) { this.camera_name = camera_name; this.camera = camera; this.fsid = fsid; this.folder = folder; this.filename = filename; this.file_size = file_size; this.modification_time = modification_time; indexable_keywords = prepare_indexable_string(filename); } protected void set_preview(Gdk.Pixbuf? preview) { this.preview = preview; } public string get_camera_name() { return camera_name; } public GPhoto.Camera get_camera() { return camera; } public int get_fsid() { return fsid; } public string get_folder() { return folder; } public string get_filename() { return filename; } public ulong get_filesize() { return file_size; } public time_t get_modification_time() { return modification_time; } public virtual Gdk.Pixbuf? get_preview() { return preview; } public virtual time_t get_exposure_time() { return get_modification_time(); } public string? get_fulldir() { return ImportPage.get_fulldir(get_camera(), get_camera_name(), get_fsid(), get_folder()); } public override string to_string() { return "%s %s/%s".printf(get_camera_name(), get_folder(), get_filename()); } public override bool internal_delete_backing() throws Error { debug("Deleting %s from %s", to_string(), camera_name); string? fulldir = get_fulldir(); if (fulldir == null) { warning("Skipping deleting %s from %s: invalid folder name", to_string(), camera_name); return base.internal_delete_backing(); } GPhoto.Result result = get_camera().delete_file(fulldir, get_filename(), ImportPage.spin_idle_context.context); if (result != GPhoto.Result.OK) warning("Error deleting %s from %s: %s", to_string(), camera_name, result.to_full_string()); return base.internal_delete_backing() && (result == GPhoto.Result.OK); } public unowned string? get_indexable_keywords() { return indexable_keywords; } } class VideoImportSource : ImportSource { public VideoImportSource(string camera_name, GPhoto.Camera camera, int fsid, string folder, string filename, ulong file_size, time_t modification_time) { base(camera_name, camera, fsid, folder, filename, file_size, modification_time); } public override Gdk.Pixbuf? get_thumbnail(int scale) throws Error { return create_thumbnail(scale); } public override Gdk.Pixbuf? create_thumbnail(int scale) throws Error { if (get_preview() == null) return null; // this satifies the return-a-new-instance requirement of create_thumbnail( ) because // scale_pixbuf( ) allocates a new pixbuf return (scale > 0) ? scale_pixbuf(get_preview(), scale, Gdk.InterpType.BILINEAR, true) : get_preview(); } public override string get_typename() { return "videoimport"; } public override int64 get_instance_id() { return get_object_id(); } public override PhotoFileFormat get_preferred_thumbnail_format() { return PhotoFileFormat.get_system_default_format(); } public override string get_name() { return get_filename(); } public void update(Gdk.Pixbuf? preview) { set_preview((preview != null) ? preview : Resources.get_noninterpretable_badge_pixbuf()); } } class PhotoImportSource : ImportSource { public const Gdk.InterpType INTERP = Gdk.InterpType.BILINEAR; private PhotoFileFormat file_format; private string? preview_md5 = null; private PhotoMetadata? metadata = null; private string? exif_md5 = null; private PhotoImportSource? associated = null; // JPEG source for RAW+JPEG public PhotoImportSource(string camera_name, GPhoto.Camera camera, int fsid, string folder, string filename, ulong file_size, time_t modification_time, PhotoFileFormat file_format) { base(camera_name, camera, fsid, folder, filename, file_size, modification_time); this.file_format = file_format; } public override string get_name() { string? title = get_title(); return !is_string_empty(title) ? title : get_filename(); } public override string get_typename() { return "photoimport"; } public override int64 get_instance_id() { return get_object_id(); } public override PhotoFileFormat get_preferred_thumbnail_format() { return (file_format.can_write()) ? file_format : PhotoFileFormat.get_system_default_format(); } public override Gdk.Pixbuf? create_thumbnail(int scale) throws Error { if (get_preview() == null) return null; // this satifies the return-a-new-instance requirement of create_thumbnail( ) because // scale_pixbuf( ) allocates a new pixbuf return (scale > 0) ? scale_pixbuf(get_preview(), scale, INTERP, true) : get_preview(); } // Needed because previews and exif are loaded after other information has been gathered. public void update(Gdk.Pixbuf? preview, string? preview_md5, PhotoMetadata? metadata, string? exif_md5) { set_preview(preview); this.preview_md5 = preview_md5; this.metadata = metadata; this.exif_md5 = exif_md5; } public override time_t get_exposure_time() { if (metadata == null) return get_modification_time(); MetadataDateTime? date_time = metadata.get_exposure_date_time(); return (date_time != null) ? date_time.get_timestamp() : get_modification_time(); } public string? get_title() { return (metadata != null) ? metadata.get_title() : null; } public PhotoMetadata? get_metadata() { if (associated != null) return associated.get_metadata(); return metadata; } public override Gdk.Pixbuf? get_preview() { if (associated != null) return associated.get_preview(); if (base.get_preview() != null) return base.get_preview(); return null; } public override Gdk.Pixbuf? get_thumbnail(int scale) throws Error { if (get_preview() == null) return null; return (scale > 0) ? scale_pixbuf(get_preview(), scale, INTERP, true) : get_preview(); } public PhotoFileFormat get_file_format() { return file_format; } public string? get_preview_md5() { return preview_md5; } public void set_associated(PhotoImportSource? associated) { this.associated = associated; } public PhotoImportSource? get_associated() { return associated; } public override bool internal_delete_backing() throws Error { bool ret = base.internal_delete_backing(); if (associated != null) ret &= associated.internal_delete_backing(); return ret; } } class ImportPreview : MediaSourceItem { public const int MAX_SCALE = 128; private static Gdk.Pixbuf placeholder_preview = null; private DuplicatedFile? duplicated_file; public ImportPreview(ImportSource source) { base(source, Dimensions(), source.get_name(), null); this.duplicated_file = null; // scale down pixbuf if necessary Gdk.Pixbuf pixbuf = null; try { pixbuf = source.get_thumbnail(0); } catch (Error err) { warning("Unable to fetch loaded import preview for %s: %s", to_string(), err.message); } // use placeholder if no preview available bool using_placeholder = (pixbuf == null); if (pixbuf == null) { if (placeholder_preview == null) { placeholder_preview = get_placeholder_pixbuf(); placeholder_preview = scale_pixbuf(placeholder_preview, MAX_SCALE, Gdk.InterpType.BILINEAR, true); } pixbuf = placeholder_preview; } // scale down if too large if (pixbuf.get_width() > MAX_SCALE || pixbuf.get_height() > MAX_SCALE) pixbuf = scale_pixbuf(pixbuf, MAX_SCALE, PhotoImportSource.INTERP, false); if (source is PhotoImportSource) { // honor rotation for photos -- we don't care about videos since they can't be rotated PhotoImportSource photo_import_source = source as PhotoImportSource; if (!using_placeholder && photo_import_source.get_metadata() != null) pixbuf = photo_import_source.get_metadata().get_orientation().rotate_pixbuf(pixbuf); if (photo_import_source.get_associated() != null) { set_subtitle("%s".printf(_("RAW+JPEG")), true); } } set_image(pixbuf); } public bool is_already_imported() { PhotoImportSource photo_import_source = get_import_source() as PhotoImportSource; if (photo_import_source != null) { string? preview_md5 = photo_import_source.get_preview_md5(); PhotoFileFormat file_format = photo_import_source.get_file_format(); // ignore trashed duplicates if (!is_string_empty(preview_md5) && LibraryPhoto.has_nontrash_duplicate(null, preview_md5, null, file_format)) { duplicated_file = DuplicatedFile.create_from_photo_id( LibraryPhoto.get_nontrash_duplicate(null, preview_md5, null, file_format)); return true; } // Because gPhoto doesn't reliably return thumbnails for RAW files, and because we want // to avoid downloading huge RAW files during an "import all" only to determine they're // duplicates, use the image's basename and filesize to do duplicate detection if (file_format == PhotoFileFormat.RAW) { uint64 filesize = get_import_source().get_filesize(); // unlikely to be a problem, but what the hay if (filesize <= int64.MAX) { PhotoID duplicated_photo_id = LibraryPhoto.global.get_basename_filesize_duplicate( get_import_source().get_filename(), (int64) filesize); if (duplicated_photo_id.is_valid()) { // Check exposure timestamp LibraryPhoto duplicated_photo = LibraryPhoto.global.fetch(duplicated_photo_id); time_t photo_exposure_time = photo_import_source.get_exposure_time(); time_t duplicated_photo_exposure_time = duplicated_photo.get_exposure_time(); if (photo_exposure_time == duplicated_photo_exposure_time) { duplicated_file = DuplicatedFile.create_from_photo_id( LibraryPhoto.global.get_basename_filesize_duplicate( get_import_source().get_filename(), (int64) filesize)); return true; } } } } return false; } VideoImportSource video_import_source = get_import_source() as VideoImportSource; if (video_import_source != null) { // Unlike photos, if a video does have a thumbnail (i.e. gphoto2 can retrieve one from // a sidecar file), it will be unavailable to Shotwell during the import process, so // no comparison is available. Instead, like RAW files, use name and filesize to // do a less-reliable but better-than-nothing comparison if (Video.global.has_basename_filesize_duplicate(video_import_source.get_filename(), video_import_source.get_filesize())) { duplicated_file = DuplicatedFile.create_from_video_id( Video.global.get_basename_filesize_duplicate( video_import_source.get_filename(), video_import_source.get_filesize())); return true; } return false; } return false; } public DuplicatedFile? get_duplicated_file() { if (!is_already_imported()) return null; return duplicated_file; } public ImportSource get_import_source() { return (ImportSource) get_source(); } protected override Gdk.Pixbuf? get_top_left_trinket(int scale) { return (get_import_source() is VideoImportSource) ? Resources.get_video_trinket (scale) : null; } } public class CameraViewTracker : Core.ViewTracker { public CameraAccumulator all = new CameraAccumulator(); public CameraAccumulator visible = new CameraAccumulator(); public CameraAccumulator selected = new CameraAccumulator(); public CameraViewTracker(ViewCollection collection) { base (collection); start(all, visible, selected); } } public class CameraAccumulator : Object, Core.TrackerAccumulator { public int total { get; private set; default = 0; } public int photos { get; private set; default = 0; } public int videos { get; private set; default = 0; } public int raw { get; private set; default = 0; } public bool include(DataObject object) { ImportSource source = (ImportSource) ((DataView) object).get_source(); total++; PhotoImportSource? photo = source as PhotoImportSource; if (photo != null && photo.get_file_format() != PhotoFileFormat.RAW) photos++; else if (photo != null && photo.get_file_format() == PhotoFileFormat.RAW) raw++; else if (source is VideoImportSource) videos++; // because of total, always fire "updated" return true; } public bool uninclude(DataObject object) { ImportSource source = (ImportSource) ((DataView) object).get_source(); total++; PhotoImportSource? photo = source as PhotoImportSource; if (photo != null && photo.get_file_format() != PhotoFileFormat.RAW) { assert(photos > 0); photos--; } else if (photo != null && photo.get_file_format() == PhotoFileFormat.RAW) { assert(raw > 0); raw--; } else if (source is VideoImportSource) { assert(videos > 0); videos--; } // because of total, always fire "updated" return true; } public bool altered(DataObject object, Alteration alteration) { // no alteration affects accumulated data return false; } public string to_string() { return "%d total/%d photos/%d videos/%d raw".printf(total, photos, videos, raw); } } public class ImportPage : CheckerboardPage { private const string UNMOUNT_FAILED_MSG = _("Unable to unmount camera. Try unmounting the camera from the file manager."); private class ImportViewManager : ViewManager { private ImportPage owner; public ImportViewManager(ImportPage owner) { this.owner = owner; } public override DataView create_view(DataSource source) { return new ImportPreview((ImportSource) source); } } private class CameraImportJob : BatchImportJob { private GPhoto.ContextWrapper context; private ImportSource import_file; private GPhoto.Camera camera; private string fulldir; private string filename; private uint64 filesize; private PhotoMetadata metadata; private time_t exposure_time; private CameraImportJob? associated = null; private BackingPhotoRow? associated_file = null; private DuplicatedFile? duplicated_file; public CameraImportJob(GPhoto.ContextWrapper context, ImportSource import_file, DuplicatedFile? duplicated_file = null) { this.context = context; this.import_file = import_file; this.duplicated_file = duplicated_file; // stash everything called in prepare(), as it may/will be called from a separate thread camera = import_file.get_camera(); fulldir = import_file.get_fulldir(); // this should've been caught long ago when the files were first enumerated assert(fulldir != null); filename = import_file.get_filename(); filesize = import_file.get_filesize(); metadata = (import_file is PhotoImportSource) ? (import_file as PhotoImportSource).get_metadata() : null; exposure_time = import_file.get_exposure_time(); } public time_t get_exposure_time() { return exposure_time; } public override DuplicatedFile? get_duplicated_file() { return duplicated_file; } public override time_t get_exposure_time_override() { return (import_file is VideoImportSource) ? get_exposure_time() : 0; } public override string get_dest_identifier() { return filename; } public override string get_source_identifier() { return import_file.get_filename(); } public override string get_basename() { return filename; } public override string get_path() { return fulldir; } public override void set_associated(BatchImportJob associated) { this.associated = associated as CameraImportJob; } public ImportSource get_source() { return import_file; } public override bool is_directory() { return false; } public override bool determine_file_size(out uint64 filesize, out File file) { file = null; filesize = this.filesize; return true; } public override bool prepare(out File file_to_import, out bool copy_to_library) throws Error { file_to_import = null; copy_to_library = false; File dest_file = null; try { bool collision; dest_file = LibraryFiles.generate_unique_file(filename, metadata, exposure_time, out collision); } catch (Error err) { warning("Unable to generate local file for %s: %s", import_file.get_filename(), err.message); } if (dest_file == null) { message("Unable to generate local file for %s", import_file.get_filename()); return false; } // always blacklist the copied images from the LibraryMonitor, otherwise it'll think // they should be auto-imported LibraryMonitor.blacklist_file(dest_file, "CameraImportJob.prepare"); try { GPhoto.save_image(context.context, camera, fulldir, filename, dest_file); } finally { LibraryMonitor.unblacklist_file(dest_file); } // Copy over associated file, if it exists. if (associated != null) { try { associated_file = RawDeveloper.CAMERA.create_backing_row_for_development(dest_file.get_path(), associated.get_basename()); } catch (Error err) { warning("Unable to generate backing associated file for %s: %s", associated.filename, err.message); } if (associated_file == null) { message("Unable to generate backing associated file for %s", associated.filename); return false; } File assoc_dest = File.new_for_path(associated_file.filepath); LibraryMonitor.blacklist_file(assoc_dest, "CameraImportJob.prepare"); try { GPhoto.save_image(context.context, camera, associated.fulldir, associated.filename, assoc_dest); } finally { LibraryMonitor.unblacklist_file(assoc_dest); } } file_to_import = dest_file; copy_to_library = false; return true; } public override File? get_associated_file() { if (associated_file == null) { return null; } return File.new_for_path(associated_file.filepath); } } private class ImportPageSearchViewFilter : SearchViewFilter { public override uint get_criteria() { return SearchFilterCriteria.TEXT | SearchFilterCriteria.MEDIA; } public override bool predicate(DataView view) { ImportSource source = ((ImportPreview) view).get_import_source(); // Media type. if ((bool) (SearchFilterCriteria.MEDIA & get_criteria()) && filter_by_media_type()) { if (source is VideoImportSource) { if (!show_media_video) return false; } else if (source is PhotoImportSource) { PhotoImportSource photo = source as PhotoImportSource; if (photo.get_file_format() == PhotoFileFormat.RAW) { if (photo.get_associated() != null) { if (!show_media_photos && !show_media_raw) return false; } else if (!show_media_raw) { return false; } } else if (!show_media_photos) return false; } } if ((bool) (SearchFilterCriteria.TEXT & get_criteria())) { unowned string? keywords = source.get_indexable_keywords(); if (is_string_empty(keywords)) return false; // Return false if the word isn't found, true otherwise. foreach (unowned string word in get_search_filter_words()) { if (!keywords.contains(word)) return false; } } return true; } } // View filter for already imported filter. private class HideImportedViewFilter : ViewFilter { public override bool predicate(DataView view) { return !((ImportPreview) view).is_already_imported(); } } public static GPhoto.ContextWrapper null_context = null; public static GPhoto.SpinIdleWrapper spin_idle_context = null; private SourceCollection import_sources = null; private Gtk.Label camera_label = new Gtk.Label(null); private Gtk.CheckButton hide_imported; private Gtk.ProgressBar progress_bar = new Gtk.ProgressBar(); private GPhoto.Camera camera; private string uri; private bool busy = false; private bool refreshed = false; private GPhoto.Result refresh_result = GPhoto.Result.OK; private string refresh_error = null; private string camera_name; private VolumeMonitor volume_monitor = null; private ImportPage? local_ref = null; private string? icon; private ImportPageSearchViewFilter search_filter = new ImportPageSearchViewFilter(); private HideImportedViewFilter hide_imported_filter = new HideImportedViewFilter(); private CameraViewTracker tracker; #if UNITY_SUPPORT UnityProgressBar uniprobar = UnityProgressBar.get_instance(); #endif public enum RefreshResult { OK, BUSY, LOCKED, LIBRARY_ERROR } public ImportPage(GPhoto.Camera camera, string uri, string? display_name = null, string? icon = null) { base(_("Camera")); this.camera = camera; this.uri = uri; this.import_sources = new ImportSourceCollection("ImportSources for %s".printf(uri)); this.icon = icon; tracker = new CameraViewTracker(get_view()); // Get camera name. if (null != display_name) { camera_name = display_name; } else { GPhoto.CameraAbilities abilities; GPhoto.Result res = camera.get_abilities(out abilities); if (res != GPhoto.Result.OK) { debug("Unable to get camera abilities: %s", res.to_full_string()); camera_name = _("Camera"); } } camera_label.set_text(camera_name); set_page_name(camera_name); // Mount.unmounted signal is *only* fired when a VolumeMonitor has been instantiated. this.volume_monitor = VolumeMonitor.get(); // set up the global null context when needed if (null_context == null) null_context = new GPhoto.ContextWrapper(); // same with idle-loop wrapper if (spin_idle_context == null) spin_idle_context = new GPhoto.SpinIdleWrapper(); // monitor source collection to add/remove views get_view().monitor_source_collection(import_sources, new ImportViewManager(this), null); // sort by exposure time get_view().set_comparator(preview_comparator, preview_comparator_predicate); // monitor selection for UI get_view().items_state_changed.connect(on_view_changed); get_view().contents_altered.connect(on_view_changed); get_view().items_visibility_changed.connect(on_view_changed); // Show subtitles. get_view().set_property(CheckerboardItem.PROP_SHOW_SUBTITLES, true); // monitor Photos for removals, as that will change the result of the ViewFilter LibraryPhoto.global.contents_altered.connect(on_media_added_removed); Video.global.contents_altered.connect(on_media_added_removed); init_item_context_menu("ImportContextMenu"); init_page_context_menu("ImportContextMenu"); } ~ImportPage() { LibraryPhoto.global.contents_altered.disconnect(on_media_added_removed); Video.global.contents_altered.disconnect(on_media_added_removed); } public override Gtk.Toolbar get_toolbar() { if (toolbar == null) { base.get_toolbar(); // hide duplicates checkbox hide_imported = new Gtk.CheckButton.with_label(_("Hide photos already imported")); hide_imported.set_tooltip_text(_("Only display photos that have not been imported")); hide_imported.clicked.connect(on_hide_imported); hide_imported.sensitive = false; hide_imported.active = Config.Facade.get_instance().get_hide_photos_already_imported(); Gtk.ToolItem hide_item = new Gtk.ToolItem(); hide_item.is_important = true; hide_item.add(hide_imported); toolbar.insert(hide_item, -1); // separator to force buttons to right side of toolbar Gtk.SeparatorToolItem separator = new Gtk.SeparatorToolItem(); separator.set_draw(false); toolbar.insert(separator, -1); // progress bar in center of toolbar progress_bar.set_orientation(Gtk.Orientation.HORIZONTAL); progress_bar.visible = false; Gtk.ToolItem progress_item = new Gtk.ToolItem(); progress_item.set_expand(true); progress_item.add(progress_bar); progress_bar.set_show_text(true); toolbar.insert(progress_item, -1); // Find button Gtk.ToggleToolButton find_button = new Gtk.ToggleToolButton(); find_button.set_icon_name("edit-find-symbolic"); find_button.set_action_name ("win.CommonDisplaySearchbar"); toolbar.insert(find_button, -1); // Separator toolbar.insert(new Gtk.SeparatorToolItem(), -1); // Import selected Gtk.ToolButton import_selected_button = new Gtk.ToolButton(null, null); import_selected_button.set_icon_name(Resources.IMPORT); import_selected_button.set_label(_("Import _Selected")); import_selected_button.is_important = true; import_selected_button.use_underline = true; import_selected_button.set_action_name ("win.ImportSelected"); toolbar.insert(import_selected_button, -1); // Import all Gtk.ToolButton import_all_button = new Gtk.ToolButton(null, null); import_all_button.set_icon_name(Resources.IMPORT_ALL); import_all_button.set_label(_("Import _All")); import_all_button.is_important = true; import_all_button.use_underline = true; import_all_button.set_action_name ("win.ImportAll"); toolbar.insert(import_all_button, -1); // restrain the recalcitrant rascal! prevents the progress bar from being added to the // show_all queue so we have more control over its visibility progress_bar.set_no_show_all(true); update_toolbar_state(); show_all(); } return toolbar; } public override Core.ViewTracker? get_view_tracker() { return tracker; } protected override string get_view_empty_message() { return _("The camera seems to be empty. No photos/videos found to import"); } protected override string get_filter_no_match_message () { return _("No new photos/videos found on camera"); } private static int64 preview_comparator(void *a, void *b) { return ((ImportPreview *) a)->get_import_source().get_exposure_time() - ((ImportPreview *) b)->get_import_source().get_exposure_time(); } private static bool preview_comparator_predicate(DataObject object, Alteration alteration) { return alteration.has_detail("metadata", "exposure-time"); } private int64 import_job_comparator(void *a, void *b) { return ((CameraImportJob *) a)->get_exposure_time() - ((CameraImportJob *) b)->get_exposure_time(); } protected override void init_collect_ui_filenames(Gee.List ui_filenames) { base.init_collect_ui_filenames(ui_filenames); ui_filenames.add("import.ui"); } private const GLib.ActionEntry[] entries = { { "ImportSelected", on_import_selected }, { "ImportAll", on_import_all }, // Toggle actions { "ViewTitle", on_action_toggle, null, "false", on_display_titles }, }; protected override void add_actions (GLib.ActionMap map) { base.add_actions (map); map.add_action_entries (entries, this); get_action ("ViewTitle").change_state (Config.Facade.get_instance ().get_display_photo_titles ()); } protected override void remove_actions(GLib.ActionMap map) { base.remove_actions(map); foreach (var entry in entries) { map.remove_action(entry.name); } } public GPhoto.Camera get_camera() { return camera; } public string get_uri() { return uri; } public bool is_busy() { return busy; } protected override void init_actions(int selected_count, int count) { on_view_changed(); set_action_sensitive("ImportSelected", true); set_action_sensitive("ImportAll", true); base.init_actions(selected_count, count); } public bool is_refreshed() { return refreshed && !busy; } public string? get_refresh_message() { string msg = null; if (refresh_error != null) { msg = refresh_error; } else if (refresh_result == GPhoto.Result.OK) { // all went well } else { msg = refresh_result.to_full_string(); } return msg; } private void update_status(bool busy, bool refreshed) { this.busy = busy; this.refreshed = refreshed; on_view_changed(); } private void update_toolbar_state() { if (hide_imported != null) hide_imported.sensitive = !busy && refreshed && (get_view().get_unfiltered_count() > 0); } private void on_view_changed() { set_action_sensitive("ImportSelected", !busy && refreshed && get_view().get_selected_count() > 0); set_action_sensitive("ImportAll", !busy && refreshed && get_view().get_count() > 0); set_action_sensitive("CommonSelectAll", !busy && (get_view().get_count() > 0)); update_toolbar_state(); } private void on_media_added_removed() { search_filter.refresh(); } private void on_display_titles(GLib.SimpleAction action, Variant? value) { bool display = value.get_boolean (); set_display_titles(display); Config.Facade.get_instance().set_display_photo_titles(display); action.set_state (value); } public override void switched_to() { set_display_titles(Config.Facade.get_instance().get_display_photo_titles()); base.switched_to(); } public override void ready() { try_refreshing_camera(false); hide_imported_filter.refresh(); } private void try_refreshing_camera(bool fail_on_locked) { // if camera has been refreshed or is in the process of refreshing, go no further if (refreshed || busy) return; RefreshResult res = refresh_camera(); switch (res) { case ImportPage.RefreshResult.OK: case ImportPage.RefreshResult.BUSY: // nothing to report; if busy, let it continue doing its thing // (although earlier check should've caught this) break; case ImportPage.RefreshResult.LOCKED: if (fail_on_locked) { AppWindow.error_message(UNMOUNT_FAILED_MSG); break; } // if locked because it's mounted, offer to unmount debug("Checking if %s is mounted…", uri); File uri = File.new_for_uri(uri); Mount mount = null; try { mount = uri.find_enclosing_mount(null); } catch (Error err) { // error means not mounted } // Could not find mount for gphoto2://, re-try with mtp:// // It seems some devices are mounted using MTP and not gphoto2 daemon if (mount == null && this.uri.has_prefix("gphoto2")) { uri = File.new_for_uri("mtp" + this.uri.substring(7)); try { mount = uri.find_enclosing_mount(null); } catch (Error err) { // error means not mounted } } if (mount != null) { // it's mounted, offer to unmount for the user string mounted_message = _("Shotwell needs to unmount the camera from the filesystem in order to access it. Continue?"); Gtk.MessageDialog dialog = new Gtk.MessageDialog(AppWindow.get_instance(), Gtk.DialogFlags.MODAL, Gtk.MessageType.QUESTION, Gtk.ButtonsType.CANCEL, "%s", mounted_message); dialog.title = Resources.APP_TITLE; dialog.add_button(_("_Unmount"), Gtk.ResponseType.YES); int dialog_res = dialog.run(); dialog.destroy(); if (dialog_res != Gtk.ResponseType.YES) { set_page_message(_("Please unmount the camera.")); } else { unmount_camera(mount); } } else { string locked_message = _("The camera is locked by another application. Shotwell can only access the camera when it’s unlocked. Please close any other application using the camera and try again."); // it's not mounted, so another application must have it locked Gtk.MessageDialog dialog = new Gtk.MessageDialog(AppWindow.get_instance(), Gtk.DialogFlags.MODAL, Gtk.MessageType.WARNING, Gtk.ButtonsType.OK, "%s", locked_message); dialog.title = Resources.APP_TITLE; dialog.run(); dialog.destroy(); set_page_message(_("Please close any other application using the camera.")); } break; case ImportPage.RefreshResult.LIBRARY_ERROR: AppWindow.error_message(_("Unable to fetch previews from the camera:\n%s").printf( get_refresh_message())); break; default: error("Unknown result type %d", (int) res); } } public bool unmount_camera(Mount mount) { if (busy) return false; update_status(true, false); progress_bar.visible = true; progress_bar.set_fraction(0.0); progress_bar.set_ellipsize(Pango.EllipsizeMode.NONE); progress_bar.set_text(_("Unmounting…")); // unmount_with_operation() can/will complete with the volume still mounted (probably meaning // it's been *scheduled* for unmounting). However, this signal is fired when the mount // really is unmounted -- *if* a VolumeMonitor has been instantiated. mount.unmounted.connect(on_unmounted); debug("Unmounting camera…"); mount.unmount_with_operation.begin(MountUnmountFlags.NONE, new Gtk.MountOperation(AppWindow.get_instance()), null, on_unmount_finished); return true; } private void on_unmount_finished(Object? source, AsyncResult aresult) { debug("Async unmount finished"); Mount mount = (Mount) source; try { mount.unmount_with_operation.end(aresult); } catch (Error err) { AppWindow.error_message(UNMOUNT_FAILED_MSG); // don't trap this signal, even if it does come in, we've backed off mount.unmounted.disconnect(on_unmounted); update_status(false, refreshed); progress_bar.set_ellipsize(Pango.EllipsizeMode.NONE); progress_bar.set_text(""); progress_bar.visible = false; } } private void on_unmounted(Mount mount) { debug("on_unmounted"); update_status(false, refreshed); progress_bar.set_ellipsize(Pango.EllipsizeMode.NONE); progress_bar.set_text(""); progress_bar.visible = false; try_refreshing_camera(true); } private void clear_all_import_sources() { Marker marker = import_sources.start_marking(); marker.mark_all(); import_sources.destroy_marked(marker, false); } /** * @brief Returns whether the current device has a given directory or not. * * @param fsid The file system id of the camera or other device to search. * @param dir The path to start searching from. * @param search_target The name of the directory to look for. */ private bool check_directory_exists(int fsid, string dir, string search_target) { string? fulldir = get_fulldir(camera, camera_name, fsid, dir); GPhoto.Result result; GPhoto.CameraList folders; result = GPhoto.CameraList.create(out folders); if (result != GPhoto.Result.OK) { // couldn't create a list - can't determine whether specified dir is present return false; } result = camera.list_folders(fulldir, folders, spin_idle_context.context); if (result != GPhoto.Result.OK) { // fetching the list failed - can't determine whether specified dir is present return false; } int list_len = folders.count(); for(int list_index = 0; list_index < list_len; list_index++) { string tmp; folders.get_name(list_index, out tmp); if (tmp == search_target) { return true; } } return false; } private int claim_timeout = 500; private RefreshResult refresh_camera() { if (busy) return RefreshResult.BUSY; this.set_page_message (_("Connecting to camera, please wait…")); update_status(busy, false); refresh_error = null; refresh_result = camera.init(spin_idle_context.context); // If we fail to claim the device, we might have run into a conflict // with gvfs-gphoto2-volume-monitor. Back off, try again after // claim_timeout ms. // We will wait 3.5s in total (500 + 1000 + 2000) before giving // up with the infamous -53 error dialog. if (refresh_result == GPhoto.Result.IO_USB_CLAIM) { if (claim_timeout < 4000) { Timeout.add (claim_timeout, () => { refresh_camera(); return false; }); claim_timeout *= 2; return RefreshResult.LOCKED; } } // reset claim_timeout to initial value claim_timeout = 500; if (refresh_result != GPhoto.Result.OK) { warning("Unable to initialize camera: %s", refresh_result.to_full_string()); return (refresh_result == GPhoto.Result.IO_LOCK) ? RefreshResult.LOCKED : RefreshResult.LIBRARY_ERROR; } this.set_page_message (_("Starting import, please wait…")); update_status(true, refreshed); on_view_changed(); progress_bar.set_ellipsize(Pango.EllipsizeMode.NONE); progress_bar.set_text(_("Fetching photo information")); progress_bar.set_fraction(0.0); progress_bar.set_pulse_step(0.01); progress_bar.visible = true; Gee.ArrayList import_list = new Gee.ArrayList(); GPhoto.CameraStorageInformation *sifs = null; int count = 0; refresh_result = camera.get_storageinfo(&sifs, out count, spin_idle_context.context); if (refresh_result == GPhoto.Result.OK) { for (int fsid = 0; fsid < count; fsid++) { // Check well-known video and image paths first to prevent accidental // scanning of undesired directories (which can cause user annoyance with // some smartphones or camera-equipped media players) bool got_well_known_dir = false; // Check common paths for most primarily-still cameras, many (most?) smartphones if (check_directory_exists(fsid, "/", "DCIM")) { enumerate_files(fsid, "/DCIM", import_list); got_well_known_dir = true; } if (check_directory_exists(fsid, "/", "dcim")) { enumerate_files(fsid, "/dcim", import_list); got_well_known_dir = true; } // Check common paths for AVCHD camcorders, primarily-still // cameras that shoot .mts video files if (check_directory_exists(fsid, "/PRIVATE/", "AVCHD")) { enumerate_files(fsid, "/PRIVATE/AVCHD", import_list); got_well_known_dir = true; } if (check_directory_exists(fsid, "/private/", "avchd")) { enumerate_files(fsid, "/private/avchd", import_list); got_well_known_dir = true; } if (check_directory_exists(fsid, "/", "AVCHD")) { enumerate_files(fsid, "/AVCHD", import_list); got_well_known_dir = true; } if (check_directory_exists(fsid, "/", "avchd")) { enumerate_files(fsid, "/avchd", import_list); got_well_known_dir = true; } // Check common video paths for some Sony primarily-still // cameras if (check_directory_exists(fsid, "/PRIVATE/", "SONY")) { enumerate_files(fsid, "/PRIVATE/SONY", import_list); got_well_known_dir = true; } if (check_directory_exists(fsid, "/private/", "sony")) { enumerate_files(fsid, "/private/sony", import_list); got_well_known_dir = true; } // Check common video paths for Sony NEX3, PSP addon camera if (check_directory_exists(fsid, "/", "MP_ROOT")) { enumerate_files(fsid, "/MP_ROOT", import_list); got_well_known_dir = true; } if (check_directory_exists(fsid, "/", "mp_root")) { enumerate_files(fsid, "/mp_root", import_list); got_well_known_dir = true; } // Didn't find any of the common directories we know about // already - try scanning from device root. if (!got_well_known_dir) { if (!enumerate_files(fsid, "/", import_list)) break; } } } clear_all_import_sources(); // Associate files (for RAW+JPEG) auto_match_raw_jpeg(import_list); #if UNITY_SUPPORT //UnityProgressBar: try to draw progress bar uniprobar.set_visible(true); #endif load_previews_and_metadata(import_list); #if UNITY_SUPPORT //UnityProgressBar: reset uniprobar.reset(); #endif progress_bar.visible = false; progress_bar.set_ellipsize(Pango.EllipsizeMode.NONE); progress_bar.set_text(""); progress_bar.set_fraction(0.0); GPhoto.Result res = camera.exit(spin_idle_context.context); if (res != GPhoto.Result.OK) { // log but don't fail warning("Unable to unlock camera: %s", res.to_full_string()); } if (refresh_result == GPhoto.Result.OK) { if (import_sources.get_count () == 0) { this.set_page_message (this.get_view_empty_message ()); } update_status(false, true); } else { update_status(false, false); // show 'em all or show none clear_all_import_sources(); } on_view_changed(); switch (refresh_result) { case GPhoto.Result.OK: return RefreshResult.OK; case GPhoto.Result.IO_LOCK: return RefreshResult.LOCKED; default: return RefreshResult.LIBRARY_ERROR; } } private static string chomp_ch(string str, char ch) { long offset = str.length; while (--offset >= 0) { if (str[offset] != ch) return str.slice(0, offset); } return ""; } public static string append_path(string basepath, string addition) { if (!basepath.has_suffix("/") && !addition.has_prefix("/")) return basepath + "/" + addition; else if (basepath.has_suffix("/") && addition.has_prefix("/")) return chomp_ch(basepath, '/') + addition; else return basepath + addition; } // Need to do this because some phones (iPhone, in particular) changes the name of their filesystem // between each mount public static string? get_fs_basedir(GPhoto.Camera camera, int fsid) { GPhoto.CameraStorageInformation *sifs = null; int count = 0; GPhoto.Result res = camera.get_storageinfo(&sifs, out count, null_context.context); if (res != GPhoto.Result.OK) return null; if (fsid >= count) return null; GPhoto.CameraStorageInformation *ifs = sifs + fsid; return (ifs->fields & GPhoto.CameraStorageInfoFields.BASE) != 0 ? ifs->basedir : "/"; } public static string? get_fulldir(GPhoto.Camera camera, string camera_name, int fsid, string folder) { if (folder.length > GPhoto.MAX_BASEDIR_LENGTH) return null; string basedir = get_fs_basedir(camera, fsid); if (basedir == null) { debug("Unable to find base directory for %s fsid %d", camera_name, fsid); return folder; } return append_path(basedir, folder); } private bool enumerate_files(int fsid, string dir, Gee.ArrayList import_list) { string? fulldir = get_fulldir(camera, camera_name, fsid, dir); if (fulldir == null) { warning("Skipping enumerating %s: invalid folder name", dir); return true; } GPhoto.CameraList files; refresh_result = GPhoto.CameraList.create(out files); if (refresh_result != GPhoto.Result.OK) { warning("Unable to create file list: %s", refresh_result.to_full_string()); return false; } refresh_result = camera.list_files(fulldir, files, spin_idle_context.context); if (refresh_result != GPhoto.Result.OK) { warning("Unable to list files in %s: %s", fulldir, refresh_result.to_full_string()); // Although an error, don't abort the import because of this refresh_result = GPhoto.Result.OK; return true; } files.sort(); for (int ctr = 0; ctr < files.count(); ctr++) { string filename; refresh_result = files.get_name(ctr, out filename); if (refresh_result != GPhoto.Result.OK) { warning("Unable to get the name of file %d in %s: %s", ctr, fulldir, refresh_result.to_full_string()); return false; } try { GPhoto.CameraFileInfo info; if (!GPhoto.get_info(spin_idle_context.context, camera, fulldir, filename, out info)) { warning("Skipping import of %s/%s: name too long", fulldir, filename); continue; } if ((info.file.fields & GPhoto.CameraFileInfoFields.TYPE) == 0) { message("Skipping %s/%s: No file (file=%02Xh)", fulldir, filename, info.file.fields); continue; } if (VideoReader.is_supported_video_filename(filename)) { VideoImportSource video_source = new VideoImportSource(camera_name, camera, fsid, dir, filename, info.file.size, info.file.mtime); import_list.add(video_source); } else { // determine file format from type, and then from file extension PhotoFileFormat file_format = PhotoFileFormat.from_gphoto_type(info.file.type); if (file_format == PhotoFileFormat.UNKNOWN) { file_format = PhotoFileFormat.get_by_basename_extension(filename); if (file_format == PhotoFileFormat.UNKNOWN) { message("Skipping %s/%s: Not a supported file extension (%s)", fulldir, filename, info.file.type); continue; } } import_list.add(new PhotoImportSource(camera_name, camera, fsid, dir, filename, info.file.size, info.file.mtime, file_format)); } progress_bar.pulse(); // spin the event loop so the UI doesn't freeze spin_event_loop(); } catch (Error err) { warning("Error while enumerating files in %s: %s", fulldir, err.message); refresh_error = err.message; return false; } } GPhoto.CameraList folders; refresh_result = GPhoto.CameraList.create(out folders); if (refresh_result != GPhoto.Result.OK) { warning("Unable to create folder list: %s", refresh_result.to_full_string()); return false; } refresh_result = camera.list_folders(fulldir, folders, spin_idle_context.context); if (refresh_result != GPhoto.Result.OK) { warning("Unable to list folders in %s: %s", fulldir, refresh_result.to_full_string()); // Although an error, don't abort the import because of this refresh_result = GPhoto.Result.OK; return true; } for (int ctr = 0; ctr < folders.count(); ctr++) { string subdir; refresh_result = folders.get_name(ctr, out subdir); if (refresh_result != GPhoto.Result.OK) { warning("Unable to get name of folder %d: %s", ctr, refresh_result.to_full_string()); return false; } if (!enumerate_files(fsid, append_path(dir, subdir), import_list)) return false; } return true; } // Try to match RAW+JPEG pairs. private void auto_match_raw_jpeg(Gee.ArrayList import_list) { for (int i = 0; i < import_list.size; i++) { PhotoImportSource? current = import_list.get(i) as PhotoImportSource; PhotoImportSource? next = (i + 1 < import_list.size) ? import_list.get(i + 1) as PhotoImportSource : null; PhotoImportSource? prev = (i > 0) ? import_list.get(i - 1) as PhotoImportSource : null; if (current != null && current.get_file_format() == PhotoFileFormat.RAW) { string current_name; string ext; disassemble_filename(current.get_filename(), out current_name, out ext); // Try to find a matching pair. PhotoImportSource? associated = null; if (next != null && next.get_file_format() == PhotoFileFormat.JFIF) { string next_name; disassemble_filename(next.get_filename(), out next_name, out ext); if (next_name == current_name) associated = next; } if (prev != null && prev.get_file_format() == PhotoFileFormat.JFIF) { string prev_name; disassemble_filename(prev.get_filename(), out prev_name, out ext); if (prev_name == current_name) associated = prev; } // Associate! if (associated != null) { debug("Found RAW+JPEG pair: %s and %s", current.get_filename(), associated.get_filename()); current.set_associated(associated); if (!import_list.remove(associated)) { debug("Unable to associate files"); current.set_associated(null); } } } } } private void load_previews_and_metadata(Gee.List import_list) { int loaded_photos = 0; foreach (ImportSource import_source in import_list) { string filename = import_source.get_filename(); string? fulldir = import_source.get_fulldir(); if (fulldir == null) { warning("Skipping loading preview of %s: invalid folder name", import_source.to_string()); continue; } // Get JPEG pair, if available. PhotoImportSource? associated = null; if (import_source is PhotoImportSource && ((PhotoImportSource) import_source).get_associated() != null) { associated = ((PhotoImportSource) import_source).get_associated(); } progress_bar.set_ellipsize(Pango.EllipsizeMode.MIDDLE); progress_bar.set_text(_("Fetching preview for %s").printf(import_source.get_name())); // Ask GPhoto to read the current file's metadata, but only if the file is not a // video. Across every memory card and camera type I've tested (lucas, as of 10/27/2010) // GPhoto always loads null metadata for videos. So without the is-not-video guard, // this code segment just needlessly and annoyingly prints a warning message to the // console. PhotoMetadata? metadata = null; if (!VideoReader.is_supported_video_filename(filename)) { try { metadata = GPhoto.load_metadata(spin_idle_context.context, camera, fulldir, filename); } catch (Error err) { warning("Unable to fetch metadata for %s/%s: %s", fulldir, filename, err.message); } } // calculate EXIF's fingerprint string? exif_only_md5 = null; if (metadata != null) { exif_only_md5 = metadata.exif_hash(); } // XXX: Cannot use the metadata for the thumbnail preview because libgphoto2 // 2.4.6 has a bug where the returned EXIF data object is complete garbage. This // is fixed in 2.4.7, but need to work around this as best we can. In particular, // this means the preview orientation will be wrong and the MD5 is not generated // if the EXIF did not parse properly (see above) Gdk.Pixbuf preview = null; string? preview_md5 = null; try { string preview_fulldir = fulldir; string preview_filename = filename; if (associated != null) { preview_fulldir = associated.get_fulldir(); preview_filename = associated.get_filename(); } preview = GPhoto.load_preview(spin_idle_context.context, camera, preview_fulldir, preview_filename, out preview_md5); } catch (Error err) { // only issue the warning message if we're not reading a video. GPhoto is capable // of reading video previews about 50% of the time, so we don't want to put a guard // around this entire code segment like we did with the metadata-read segment above, // however video previews being absent is so common that there's no reason // we should generate a warning for one. if (!VideoReader.is_supported_video_filename(filename)) { warning("Unable to fetch preview for %s/%s: %s", fulldir, filename, err.message); } } #if TRACE_MD5 debug("camera MD5 %s: exif=%s preview=%s", filename, exif_only_md5, preview_md5); #endif if (import_source is VideoImportSource) (import_source as VideoImportSource).update(preview); if (import_source is PhotoImportSource) (import_source as PhotoImportSource).update(preview, preview_md5, metadata, exif_only_md5); if (associated != null) { try { PhotoMetadata? associated_metadata = GPhoto.load_metadata(spin_idle_context.context, camera, associated.get_fulldir(), associated.get_filename()); associated.update(preview, preview_md5, associated_metadata, null); } catch (Error err) { warning("Unable to fetch metadata for %s/%s: %s", associated.get_fulldir(), associated.get_filename(), err.message); } } // *now* add to the SourceCollection, now that it is completed import_sources.add(import_source); progress_bar.set_fraction((double) (++loaded_photos) / (double) import_list.size); #if UNITY_SUPPORT //UnityProgressBar: set progress uniprobar.set_progress((double) (loaded_photos) / (double) import_list.size); #endif // spin the event loop so the UI doesn't freeze spin_event_loop(); } } private void on_hide_imported() { if (hide_imported.get_active()) get_view().install_view_filter(hide_imported_filter); else get_view().remove_view_filter(hide_imported_filter); Config.Facade.get_instance().set_hide_photos_already_imported(hide_imported.get_active()); } private void on_import_selected() { import(get_view().get_selected()); } private void on_import_all() { import(get_view().get_all()); } private void import(Gee.Iterable items) { GPhoto.Result res = camera.init(spin_idle_context.context); if (res != GPhoto.Result.OK) { AppWindow.error_message(_("Unable to lock camera: %s").printf(res.to_full_string())); return; } update_status(true, refreshed); on_view_changed(); progress_bar.visible = false; SortedList jobs = new SortedList(import_job_comparator); Gee.ArrayList already_imported = new Gee.ArrayList(); foreach (DataObject object in items) { ImportPreview preview = (ImportPreview) object; ImportSource import_file = (ImportSource) preview.get_source(); if (preview.is_already_imported()) { message("Skipping import of %s: checksum detected in library", import_file.get_filename()); already_imported.add(new CameraImportJob(null_context, import_file, preview.get_duplicated_file())); continue; } CameraImportJob import_job = new CameraImportJob(null_context, import_file); // Maintain RAW+JPEG association. if (import_file is PhotoImportSource && ((PhotoImportSource) import_file).get_associated() != null) { import_job.set_associated(new CameraImportJob(null_context, ((PhotoImportSource) import_file).get_associated())); } jobs.add(import_job); } debug("Importing %d files from %s", jobs.size, camera_name); if (jobs.size > 0) { // see import_reporter() to see why this is held during the duration of the import assert(local_ref == null); local_ref = this; BatchImport batch_import = new BatchImport(jobs, camera_name, import_reporter, null, already_imported); batch_import.import_job_failed.connect(on_import_job_failed); batch_import.import_complete.connect(close_import); LibraryWindow.get_app().enqueue_batch_import(batch_import, true); LibraryWindow.get_app().switch_to_import_queue_page(); // camera.exit() and busy flag will be handled when the batch import completes } else { // since failed up-front, build a fake (faux?) ImportManifest and report it here if (already_imported.size > 0) import_reporter(new ImportManifest(null, already_imported)); close_import(); } } private void on_import_job_failed(BatchImportResult result) { if (result.file == null || result.result == ImportResult.SUCCESS) return; // delete the copied file try { result.file.delete(null); } catch (Error err) { message("Unable to delete downloaded file %s: %s", result.file.get_path(), err.message); } } private void import_reporter(ImportManifest manifest) { // TODO: Need to keep the ImportPage around until the BatchImport is completed, but the // page controller (i.e. LibraryWindow) needs to know (a) if ImportPage is busy before // removing and (b) if it is, to be notified when it ain't. Until that's in place, need // to hold the ref so the page isn't destroyed ... this switcheroo keeps the ref alive // until this function returns (at any time) ImportPage? local_ref = this.local_ref; this.local_ref = null; if (manifest.success.size > 0) { string photos_string = (ngettext("Delete this photo from camera?", "Delete these %d photos from camera?", manifest.success.size)).printf(manifest.success.size); string videos_string = (ngettext("Delete this video from camera?", "Delete these %d videos from camera?", manifest.success.size)).printf(manifest.success.size); string both_string = (ngettext("Delete this photo/video from camera?", "Delete these %d photos/videos from camera?", manifest.success.size)).printf(manifest.success.size); string neither_string = (ngettext("Delete these files from camera?", "Delete these %d files from camera?", manifest.success.size)).printf(manifest.success.size); string question_string = ImportUI.get_media_specific_string(manifest.success, photos_string, videos_string, both_string, neither_string); ImportUI.QuestionParams question = new ImportUI.QuestionParams( question_string, Resources.DELETE_LABEL, _("_Keep")); if (!ImportUI.report_manifest(manifest, false, question)) return; } else { ImportUI.report_manifest(manifest, false, null); return; } // delete the photos from the camera and the SourceCollection... for now, this is an // all-or-nothing deal Marker marker = import_sources.start_marking(); foreach (BatchImportResult batch_result in manifest.success) { CameraImportJob job = batch_result.job as CameraImportJob; marker.mark(job.get_source()); } ProgressDialog progress = new ProgressDialog(AppWindow.get_instance(), _("Removing photos/videos from camera"), new Cancellable()); int error_count = import_sources.destroy_marked(marker, true, progress.monitor); if (error_count > 0) { string error_string = (ngettext("Unable to delete %d photo/video from the camera due to errors.", "Unable to delete %d photos/videos from the camera due to errors.", error_count)).printf( error_count); AppWindow.error_message(error_string); } progress.close(); // to stop build warnings local_ref = null; } private void close_import() { GPhoto.Result res = camera.exit(spin_idle_context.context); if (res != GPhoto.Result.OK) { // log but don't fail message("Unable to unlock camera: %s", res.to_full_string()); } update_status(false, refreshed); on_view_changed(); } public override void set_display_titles(bool display) { base.set_display_titles(display); set_action_active ("ViewTitle", display); } // Gets the search view filter for this page. public override SearchViewFilter get_search_view_filter() { return search_filter; } }