diff --git a/app/src/main/java/com/geeksville/mesh/ui/MapFragment.kt b/app/src/main/java/com/geeksville/mesh/ui/MapFragment.kt index 2d7993474..2381276e0 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/MapFragment.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/MapFragment.kt @@ -1,10 +1,12 @@ package com.geeksville.mesh.ui +import android.app.AlertDialog import android.graphics.Color import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.widget.* import androidx.core.content.ContextCompat import androidx.core.graphics.drawable.toBitmap import androidx.core.view.isVisible @@ -14,44 +16,107 @@ import com.geeksville.android.GeeksvilleApplication import com.geeksville.android.Logging import com.geeksville.mesh.NodeInfo import com.geeksville.mesh.R +import com.geeksville.mesh.databinding.MapNotAllowedBinding +import com.geeksville.mesh.databinding.MapViewBinding import com.geeksville.mesh.model.UIViewModel import com.geeksville.util.formatAgo -import com.mapbox.geojson.Feature -import com.mapbox.geojson.FeatureCollection -import com.mapbox.geojson.Point +import com.mapbox.bindgen.Value +import com.mapbox.common.* +import com.mapbox.geojson.* import com.mapbox.maps.* import com.mapbox.maps.dsl.cameraOptions import com.mapbox.maps.extension.style.expressions.generated.Expression import com.mapbox.maps.extension.style.layers.addLayer +import com.mapbox.maps.extension.style.layers.addPersistentLayer +import com.mapbox.maps.extension.style.layers.generated.LineLayer import com.mapbox.maps.extension.style.layers.generated.SymbolLayer -import com.mapbox.maps.extension.style.layers.properties.generated.IconAnchor -import com.mapbox.maps.extension.style.layers.properties.generated.TextAnchor -import com.mapbox.maps.extension.style.layers.properties.generated.TextJustify +import com.mapbox.maps.extension.style.layers.generated.lineLayer +import com.mapbox.maps.extension.style.layers.properties.generated.* import com.mapbox.maps.extension.style.sources.addSource import com.mapbox.maps.extension.style.sources.generated.GeoJsonSource +import com.mapbox.maps.extension.style.sources.generated.geoJsonSource import com.mapbox.maps.plugin.animation.MapAnimationOptions import com.mapbox.maps.plugin.animation.flyTo +import com.mapbox.maps.plugin.gestures.OnMapLongClickListener import com.mapbox.maps.plugin.gestures.gestures import dagger.hilt.android.AndroidEntryPoint +import kotlin.math.cos +import kotlin.math.sin @AndroidEntryPoint class MapFragment : ScreenFragment("Map"), Logging { + + private val tileStore: TileStore by lazy { + TileStore.create().also { + // Set default access token for the created tile store instance + it.setOption( + TileStoreOptions.MAPBOX_ACCESS_TOKEN, + TileDataDomain.MAPS, + Value(getString(R.string.mapbox_access_token)) + ) + } + } + + /** + * DEVELOPER OPTION TO ENABLE OFFLINE MAPS + * Set this variable to true to enable offline maps + */ + //___________________________________________________ + private val offlineEnabled = false + //___________________________________________________ + + + private val resourceOptions: ResourceOptions by lazy { + ResourceOptions.Builder().applyDefaultParams(requireContext()).tileStore(tileStore).build() + } + private val offlineManager: OfflineManager by lazy { + OfflineManager(resourceOptions) + } + + private lateinit var binding: MapViewBinding + private lateinit var mapNotAllowedBinding: MapNotAllowedBinding + private var userStyleURI: String? = null + + private lateinit var geoJsonSource: GeoJsonSource + private lateinit var lineLayer: LineLayer + + private var point: Point? = null + private val model: UIViewModel by activityViewModels() private val nodeSourceId = "node-positions" private val nodeLayerId = "node-layer" private val labelLayerId = "label-layer" private val markerImageId = "my-marker-image" + private val userPointImageId = "user-image" + private val boundingBoxId = "bounding-box-id" + private val lineLayerId = "line-layer-id" + private var stylePackCancelable: Cancelable? = null + private var tilePackCancelable: Cancelable? = null + + private lateinit var squareRegion: Geometry + + private val userTouchPositionId = "user-touch-position" + private val userTouchLayerId = "user-touch-layer" private var nodePositions = GeoJsonSource(GeoJsonSource.Builder(nodeSourceId)) + private var tileRegionDownloadSuccess = false + private var stylePackDownloadSuccess = false + private val userTouchPosition = GeoJsonSource(GeoJsonSource.Builder(userTouchPositionId)) + + private val nodeLayer = SymbolLayer(nodeLayerId, nodeSourceId) .iconImage(markerImageId) .iconAnchor(IconAnchor.BOTTOM) .iconAllowOverlap(true) + private val userTouchLayer = SymbolLayer(userTouchLayerId, userTouchPositionId) + .iconImage(userPointImageId) + .iconAnchor(IconAnchor.BOTTOM) + private val labelLayer = SymbolLayer(labelLayerId, nodeSourceId) .textField(Expression.get("name")) .textSize(12.0) @@ -62,7 +127,7 @@ class MapFragment : ScreenFragment("Map"), Logging { .textAllowOverlap(true) - private fun onNodesChanged(map: MapboxMap, nodes: Collection) { + private fun onNodesChanged(nodes: Collection) { val nodesWithPosition = nodes.filter { it.validPosition != null } /** @@ -128,21 +193,53 @@ class MapFragment : ScreenFragment("Map"), Logging { override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? - ): View? { + ): View { // We can't allow mapbox if user doesn't want analytics - val id = - if ((requireContext().applicationContext as GeeksvilleApplication).isAnalyticsAllowed) { - // Mapbox Access token - R.layout.map_view - } else { - R.layout.map_not_allowed - } - - return inflater.inflate(id, container, false) + return if ((requireContext().applicationContext as GeeksvilleApplication).isAnalyticsAllowed) { + // Mapbox Access token + binding = MapViewBinding.inflate(inflater, container, false) + binding.root + } else { + mapNotAllowedBinding = MapNotAllowedBinding.inflate(inflater, container, false) + mapNotAllowedBinding.root + } } var mapView: MapView? = null + + private fun removeOfflineRegions() { + // Remove the tile region with the tile region ID. + // Note this will not remove the downloaded tile packs, instead, it will just mark the tileset + // not a part of a tile region. The tiles still exists as a predictive cache in TileStore. + tileStore.removeTileRegion(TILE_REGION_ID) + + // Set the disk quota to zero, so that tile regions are fully evicted + // when removed. The TileStore is also used when `ResourceOptions.isLoadTilePacksFromNetwork` + // is `true`, and also by the Navigation SDK. + // This removes the tiles that do not belong to any tile regions. + tileStore.setOption(TileStoreOptions.DISK_QUOTA, Value(0)) + + // Remove the style pack with the style url. + // Note this will not remove the downloaded style pack, instead, it will just mark the resources + // not a part of the existing style pack. The resources still exists as disk cache. + + if (userStyleURI != null) { + offlineManager.removeStylePack(userStyleURI!!) + mapView?.getMapboxMap()?.loadStyleUri(Style.OUTDOORS) + } else { + offlineManager.removeStylePack(mapView?.getMapboxMap()?.getStyle()?.styleURI.toString()) + mapView?.getMapboxMap()?.loadStyleUri(Style.OUTDOORS) + } + MapboxMap.clearData(resourceOptions) { + it.error?.let { error -> + debug(error) + } + } + updateStylePackDownloadProgress(0, 0) + updateTileRegionDownloadProgress(0, 0) + } + /** * Mapbox native code can crash painfully if you ever call a mapbox view function while the view is not actively being show */ @@ -152,6 +249,14 @@ class MapFragment : ScreenFragment("Map"), Logging { override fun onViewCreated(viewIn: View, savedInstanceState: Bundle?) { super.onViewCreated(viewIn, savedInstanceState) +// binding.fabStyleToggle.setOnClickListener { +// +// //TODO: Setup Style menu for satellite view, street view, & outdoor view +// } + binding.downloadRegion.setOnClickListener { + // Display menu for download region + this.downloadRegionDialogFragment() + } // We might not have a real mapview if running with analytics if ((requireContext().applicationContext as GeeksvilleApplication).isAnalyticsAllowed) { val vIn = viewIn.findViewById(R.id.mapView) @@ -173,30 +278,382 @@ class MapFragment : ScreenFragment("Map"), Logging { if (it.isStyleLoaded) { it.addSource(nodePositions) it.addImage(markerImageId, markerIcon) - it.addLayer(nodeLayer) - it.addLayer(labelLayer) + it.addPersistentLayer(nodeLayer) + it.addPersistentLayer(labelLayer) } } v.gestures.rotateEnabled = false + if (offlineEnabled) { + v.gestures.addOnMapLongClickListener(this.longClick) + } // Provide initial positions model.nodeDB.nodes.value?.let { nodes -> - onNodesChanged(map, nodes.values) + onNodesChanged(nodes.values) } } - // Any times nodes change update our map model.nodeDB.nodes.observe(viewLifecycleOwner, Observer { nodes -> if (isViewVisible) - onNodesChanged(map, nodes.values) + onNodesChanged(nodes.values) }) + //viewAnnotationManager = v.viewAnnotationManager zoomToNodes(map) } } } + + private fun downloadOfflineRegion(styleURI: String = "") { + + val style = styleURI.ifEmpty { + mapView?.getMapboxMap() + ?.getStyle()?.styleURI.toString() + } + + if (OfflineSwitch.getInstance().isMapboxStackConnected) { + + // By default, users may download up to 250MB of data for offline use without incurring + // additional charges. This limit is subject to change during the beta. + + // - - - - - - - - + + // 1. Create style package with loadStylePack() call. + + // A style pack (a Style offline package) contains the loaded style and its resources: loaded + // sources, fonts, sprites. Style packs are identified with their style URI. + + // Style packs are stored in the disk cache database, but their resources are not subject to + // the data eviction algorithm and are not considered when calculating the disk cache size. + + binding.stylePackDownloadProgress.visibility = View.VISIBLE + binding.stylePackText.visibility = View.VISIBLE + stylePackCancelable = offlineManager.loadStylePack( + style, + // Build Style pack load options + StylePackLoadOptions.Builder() + .glyphsRasterizationMode(GlyphsRasterizationMode.IDEOGRAPHS_RASTERIZED_LOCALLY) + .metadata(Value(STYLE_PACK_METADATA)) + .build(), + { progress -> + updateStylePackDownloadProgress( + progress.completedResourceCount, + progress.requiredResourceCount, + ) + }, + { expected -> + if (expected.isValue) { + expected.value?.let { stylePack -> + // Style pack download finishes successfully + debug("StylePack downloaded: $stylePack") + if (binding.stylePackDownloadProgress.progress == binding.stylePackDownloadProgress.max) { + debug("Style pack download complete") + binding.stylePackText.visibility = View.INVISIBLE + binding.stylePackDownloadProgress.visibility = View.INVISIBLE + stylePackDownloadSuccess = true + } else { + debug("Waiting for tile region download to be finished.") + } + } + } + expected.error?.let { + stylePackDownloadSuccess = false + // Handle error occurred during the style pack download. + binding.stylePackText.visibility = View.INVISIBLE + binding.stylePackDownloadProgress.visibility = View.INVISIBLE + debug("StylePackError: $it") + } + } + ) + + // - - - - - - - - + + // 2. Create a tile region with tiles for the outdoors style + + // A Tile Region represents an identifiable geographic tile region with metadata, consisting of + // a set of tiles packs that cover a given area (a polygon). Tile Regions allow caching tiles + // packs in an explicit way: By creating a Tile Region, developers can ensure that all tiles in + // that region will be downloaded and remain cached until explicitly deleted. + + // Creating a Tile Region requires supplying a description of the area geometry, the tilesets + // and zoom ranges of the tiles within the region. + + // The tileset descriptor encapsulates the tile-specific data, such as which tilesets, zoom ranges, + // pixel ratio etc. the cached tile packs should have. It is passed to the Tile Store along with + // the region area geometry to load a new Tile Region. + + // The OfflineManager is responsible for creating tileset descriptors for the given style and zoom range. + + val tilesetDescriptor = offlineManager.createTilesetDescriptor( + TilesetDescriptorOptions.Builder() + .styleURI(style) + .minZoom(0) + .maxZoom(10) + .build() + ) + // Use the the default TileStore to load this region. You can create custom TileStores are are + // unique for a particular file path, i.e. there is only ever one TileStore per unique path. + + // Note that the TileStore path must be the same with the TileStore used when initialise the MapView. + binding.tilePackText.visibility = View.VISIBLE + binding.tilePackDownloadProgress.visibility = View.VISIBLE + tilePackCancelable = tileStore.loadTileRegion( + TILE_REGION_ID, // Make this dynamic + TileRegionLoadOptions.Builder() + .geometry(squareRegion) + .descriptors(listOf(tilesetDescriptor)) + .metadata(Value(TILE_REGION_METADATA)) + .acceptExpired(true) + .networkRestriction(NetworkRestriction.NONE) + .build(), + { progress -> + updateTileRegionDownloadProgress( + progress.completedResourceCount, + progress.requiredResourceCount, + ) + } + ) { expected -> + if (expected.isValue) { + // Tile pack download finishes successfully + expected.value?.let { region -> + debug("TileRegion downloaded: $region") + if (binding.tilePackDownloadProgress.progress == binding.tilePackDownloadProgress.max) { + debug("Finished tilepack download") + binding.tilePackDownloadProgress.visibility = View.INVISIBLE + binding.tilePackText.visibility = View.INVISIBLE + tileRegionDownloadSuccess = true + + } else { + debug("Waiting for style pack download to be finished.") + } + } + } + expected.error?.let { + tileRegionDownloadSuccess = false + // Handle error occurred during the tile region download. + binding.tilePackDownloadProgress.visibility = View.INVISIBLE + binding.tilePackText.visibility = View.INVISIBLE + debug("TileRegionError: $it") + } + } + } else { + Toast.makeText( + requireContext(), + R.string.download_region_connection_alert, + Toast.LENGTH_LONG + ).show() + } + } + + /** + * OnLongClick of the map set a position marker. + * If a user long-clicks again, the position of the first marker will be updated + */ + private val longClick = OnMapLongClickListener { + val userDefinedPointImg = + ContextCompat.getDrawable( + requireActivity(), + R.drawable.baseline_location_on_white_24dp + )!! + .toBitmap() + point = Point.fromLngLat(it.longitude(), it.latitude()) + + + /* + Calculate region from user specified position. + 5 miles NE,NW,SE,SW from user center point. + 25 Sq Mile Region + */ + //____________________________________________________________________________________________ + val topRight = calculateCoordinate(45.0, point?.latitude()!!, point?.longitude()!!) + val topLeft = calculateCoordinate(135.0, point?.latitude()!!, point?.longitude()!!) + val bottomLeft = calculateCoordinate(225.0, point?.latitude()!!, point?.longitude()!!) + val bottomRight = calculateCoordinate(315.0, point?.latitude()!!, point?.longitude()!!) + //____________________________________________________________________________________________ + + val pointList = listOf(topRight, topLeft, bottomLeft, bottomRight, topRight) + + squareRegion = LineString.fromLngLats(pointList) + + geoJsonSource = geoJsonSource(boundingBoxId) { + geometry(squareRegion) + } + lineLayer = lineLayer(lineLayerId, boundingBoxId) { + lineCap(LineCap.ROUND) + lineJoin(LineJoin.MITER) + lineOpacity(0.7) + lineWidth(1.5) + lineColor("#888") + } + + if (point != null) { + binding.downloadRegion.visibility = View.VISIBLE + } + + mapView?.getMapboxMap()?.getStyle()?.let { style -> + userTouchPosition.geometry(point!!) + if (!style.styleLayerExists(userTouchLayerId)) { + style.addImage(userPointImageId, userDefinedPointImg) + style.addSource(userTouchPosition) + style.addSource(geoJsonSource) + style.addPersistentLayer(lineLayer) + style.addLayer(userTouchLayer) + } else { + style.removeStyleLayer(lineLayerId) + style.removeStyleSource(boundingBoxId) + style.addSource(geoJsonSource) + style.addLayer(lineLayer) + } + } + mapView?.getMapboxMap().also { mapboxMap -> + mapboxMap?.flyTo( + CameraOptions.Builder() + .zoom(ZOOM) + .center(point) + .build(), MapAnimationOptions.mapAnimationOptions { duration(1000) }) + } + return@OnMapLongClickListener true + } + + /** + * Find's coordinates (Lat,Lon) a specified distance from given (lat,lon) using degrees to determine direction + * @param degrees degree of desired position from current position. (center point is 0,0 and desired point, top right corner, is 45 degrees from 0,0) + * @param lat latitude position (current position lat) + * @param lon longitude position (current position lon) + * @return Point + */ + private fun calculateCoordinate(degrees: Double, lat: Double, lon: Double): Point { + val deg = Math.toRadians(degrees) + val distancesInMeters = + 1609.344 * 2.5 // 1609.344 is 1 mile in meters -> multiplier will be user specified up to a max of 10 + val radiusOfEarthInMeters = 6378137 + val x = + lon + (180 / Math.PI) * (distancesInMeters / radiusOfEarthInMeters) * cos( + deg + ) + val y = + lat + (180 / Math.PI) * (distancesInMeters / radiusOfEarthInMeters) * sin(deg) + return Point.fromLngLat(x, y) + } + + private fun updateStylePackDownloadProgress( + progress: Long, + max: Long, + ) { + binding.stylePackDownloadProgress.max = max.toInt() + binding.stylePackDownloadProgress.progress = progress.toInt() + } + + private fun updateTileRegionDownloadProgress( + progress: Long, + max: Long, + ) { + binding.tilePackDownloadProgress.max = max.toInt() + binding.tilePackDownloadProgress.progress = progress.toInt() + } + + companion object { + private const val ZOOM = 12.5 + private const val TILE_REGION_ID = "tile-region" + private const val STYLE_PACK_METADATA = "outdoor-style-pack" + private const val TILE_REGION_METADATA = "outdoor-tile-region" + } + + private fun downloadRegionDialogFragment() { + val mapDownloadView = layoutInflater.inflate(R.layout.dialog_map_download, null) + val uri = mapDownloadView.findViewById(R.id.uri) + val downloadRegionDialogFragment = AlertDialog.Builder(context) + + + downloadRegionDialogFragment.setView(mapDownloadView) + .setTitle(R.string.download_region_dialog_title) + .setMultiChoiceItems( + R.array.MapMenuCheckbox, + null, + ) { _, _, isChecked -> + if (isChecked) { + if (!uri.isVisible) { + uri.visibility = + View.VISIBLE + } + } else { + if (uri.isVisible) { + uri.visibility = + View.GONE + } + } + } + .setPositiveButton( + R.string.save_btn, null + ) + .setNeutralButton(R.string.view_region_btn) { _, _ -> + if (tileRegionDownloadSuccess && stylePackDownloadSuccess) { + mapView?.getMapboxMap().also { + it?.flyTo( + CameraOptions.Builder() + .zoom(ZOOM) + .center(point) + .build(), + MapAnimationOptions.mapAnimationOptions { duration(1000) }) + if (userStyleURI != null) { + it?.loadStyleUri(userStyleURI.toString()) + } else { + it?.getStyle().also { style -> + style?.removeStyleImage(userPointImageId) + } + } + } + } else { + Toast.makeText( + requireContext(), + R.string.no_download_region_alert, + Toast.LENGTH_SHORT + ).show() + } + } + .setNegativeButton( + R.string.cancel + ) { dialog, _ -> + mapView?.getMapboxMap()?.getStyle { style -> + point = null + userStyleURI = null + style.removeStyleLayer(lineLayerId) + style.removeStyleSource(boundingBoxId) + style.removeStyleLayer(userTouchLayerId) + style.removeStyleSource(userTouchPositionId) + style.removeStyleImage(userPointImageId) + } + binding.downloadRegion.visibility = View.INVISIBLE + + removeOfflineRegions() //TODO: Add to offline manager window + dialog.cancel() + } + + val dialog = downloadRegionDialogFragment.create() + dialog.show() + dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener { + if (uri.isVisible) { + if (uri.text.isNotEmpty()) { + // Save URI + userStyleURI = uri.text.toString() + uri.setText("") // clear text + + downloadOfflineRegion(userStyleURI!!) + dialog.dismiss() + } else { + Toast.makeText( + requireContext(), + R.string.style_uri_empty_alert, + Toast.LENGTH_SHORT + ).show() + } + } else { + downloadOfflineRegion() + dialog.dismiss() + } + } + } } - diff --git a/app/src/main/proto b/app/src/main/proto index f6ba3722b..7c49bdad9 160000 --- a/app/src/main/proto +++ b/app/src/main/proto @@ -1 +1 @@ -Subproject commit f6ba3722be8a51c3c0c1446bb08e730a0be568e0 +Subproject commit 7c49bdad99f53332d17796188fc89fd5c29e0ce3 diff --git a/app/src/main/res/drawable/baseline_download_white_24dp.xml b/app/src/main/res/drawable/baseline_download_white_24dp.xml new file mode 100644 index 000000000..41ed7875b --- /dev/null +++ b/app/src/main/res/drawable/baseline_download_white_24dp.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/drawable/baseline_layers_white_24dp.xml b/app/src/main/res/drawable/baseline_layers_white_24dp.xml new file mode 100644 index 000000000..02fd48926 --- /dev/null +++ b/app/src/main/res/drawable/baseline_layers_white_24dp.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/drawable/baseline_location_on_white_24dp.xml b/app/src/main/res/drawable/baseline_location_on_white_24dp.xml new file mode 100644 index 000000000..8b4720d58 --- /dev/null +++ b/app/src/main/res/drawable/baseline_location_on_white_24dp.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/layout/dialog_map_download.xml b/app/src/main/res/layout/dialog_map_download.xml new file mode 100644 index 000000000..7c205e88b --- /dev/null +++ b/app/src/main/res/layout/dialog_map_download.xml @@ -0,0 +1,19 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_gesture_alert.xml b/app/src/main/res/layout/item_gesture_alert.xml new file mode 100644 index 000000000..797354b11 --- /dev/null +++ b/app/src/main/res/layout/item_gesture_alert.xml @@ -0,0 +1,14 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/map_view.xml b/app/src/main/res/layout/map_view.xml index 132cb4ee9..7b1c190c3 100644 --- a/app/src/main/res/layout/map_view.xml +++ b/app/src/main/res/layout/map_view.xml @@ -1,8 +1,97 @@ - - \ No newline at end of file + android:layout_height="match_parent"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/offline_region_manager.xml b/app/src/main/res/layout/offline_region_manager.xml new file mode 100644 index 000000000..397e1a8c6 --- /dev/null +++ b/app/src/main/res/layout/offline_region_manager.xml @@ -0,0 +1,14 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/user_icon_menu.xml b/app/src/main/res/layout/user_icon_menu.xml new file mode 100644 index 000000000..88d8f5ec1 --- /dev/null +++ b/app/src/main/res/layout/user_icon_menu.xml @@ -0,0 +1,19 @@ + + + + + + diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml new file mode 100644 index 000000000..c0035db1a --- /dev/null +++ b/app/src/main/res/values/arrays.xml @@ -0,0 +1,6 @@ + + + + Use Custom URI? + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 84b4568cf..d23dca3ec 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -127,4 +127,14 @@ Delete Select all Long Range / Slow + Style Selection + Download Region + Name + View Region + Save + No downloaded region available + Style URI cannot be empty + Download Region + You are not connected to the internet, you cannot download an offline map + Unable to download style pack \ No newline at end of file