11 |
12 |
--------------------------------------------------------------------------------
/.idea/vcs.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Fileshare
2 |
3 | Android app to share files securely via best available method automatically detected from Direct connection (WiFi Direct, BT, etc), Local Network, Internet or Relay Server.
4 | Other transport modes are possible to add in the future if beneficial.
5 |
6 |
7 | Current development is happening in the [feature/wifi-direct](https://github.com/Rubberquacks/Fileshare/tree/feature/wifi-direct) branch for WiFi Direct / WiFi Peer to Peer (P2P) as the first transport method.
8 |
--------------------------------------------------------------------------------
/app/.gitignore:
--------------------------------------------------------------------------------
1 | /build
2 |
--------------------------------------------------------------------------------
/app/build.gradle:
--------------------------------------------------------------------------------
1 | plugins {
2 | id 'com.android.application'
3 | id 'kotlin-android'
4 | id 'kotlin-android-extensions'
5 | }
6 |
7 | android {
8 | compileSdkVersion 30
9 | buildToolsVersion "30.0.2"
10 | defaultConfig {
11 | applicationId "dev.akampf.fileshare"
12 | minSdkVersion 19
13 | targetSdkVersion 30
14 | versionCode 1
15 | versionName "1.0"
16 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
17 | }
18 | buildTypes {
19 | release {
20 | minifyEnabled false
21 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
22 | }
23 | }
24 | compileOptions {
25 | sourceCompatibility JavaVersion.VERSION_1_8
26 | targetCompatibility JavaVersion.VERSION_1_8
27 | }
28 | kotlinOptions {
29 | jvmTarget = '1.8'
30 | }
31 | }
32 |
33 | dependencies {
34 | implementation fileTree(dir: 'libs', include: ['*.jar'])
35 | implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
36 | implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.9"
37 | implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.9"
38 | implementation 'androidx.appcompat:appcompat:1.2.0'
39 | implementation 'androidx.core:core-ktx:1.3.2'
40 | implementation 'androidx.constraintlayout:constraintlayout:2.0.2'
41 | implementation 'androidx.legacy:legacy-support-v4:1.0.0'
42 | implementation 'androidx.recyclerview:recyclerview:1.1.0'
43 | testImplementation 'junit:junit:4.13'
44 | androidTestImplementation 'androidx.test.ext:junit:1.1.2'
45 | androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0'
46 | implementation 'com.google.android.material:material:1.2.1'
47 | implementation "androidx.coordinatorlayout:coordinatorlayout:1.1.0"
48 | implementation 'org.jetbrains.kotlin:kotlin-reflect:1.4.10'
49 |
50 | }
51 |
--------------------------------------------------------------------------------
/app/proguard-rules.pro:
--------------------------------------------------------------------------------
1 | # Add project specific ProGuard rules here.
2 | # You can control the set of applied configuration files using the
3 | # proguardFiles setting in build.gradle.
4 | #
5 | # For more details, see
6 | # http://developer.android.com/guide/developing/tools/proguard.html
7 |
8 | # If your project uses WebView with JS, uncomment the following
9 | # and specify the fully qualified class name to the JavaScript interface
10 | # class:
11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview {
12 | # public *;
13 | #}
14 |
15 | # Uncomment this to preserve the line number information for
16 | # debugging stack traces.
17 | #-keepattributes SourceFile,LineNumberTable
18 |
19 | # If you keep the line number information, uncomment this to
20 | # hide the original source file name.
21 | #-renamesourcefileattribute SourceFile
22 |
--------------------------------------------------------------------------------
/app/src/androidTest/java/dev/akampf/fileshare/ExampleInstrumentedTest.kt:
--------------------------------------------------------------------------------
1 | package dev.akampf.fileshare
2 |
3 | import androidx.test.platform.app.InstrumentationRegistry
4 | import androidx.test.ext.junit.runners.AndroidJUnit4
5 |
6 | import org.junit.Test
7 | import org.junit.runner.RunWith
8 |
9 | import org.junit.Assert.*
10 |
11 | /**
12 | * Instrumented test, which will execute on an Android device.
13 | *
14 | * See [testing documentation](http://d.android.com/tools/testing).
15 | */
16 | @RunWith(AndroidJUnit4::class)
17 | class ExampleInstrumentedTest {
18 | @Test
19 | fun useAppContext() {
20 | // Context of the app under test.
21 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext
22 | assertEquals("dev.akampf.fileshare", appContext.packageName)
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/app/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
4 |
5 |
13 |
14 |
15 |
18 |
23 |
24 |
27 |
32 |
33 |
36 |
38 |
39 |
42 |
43 |
44 |
45 |
52 |
53 |
56 |
57 |
58 |
59 |
60 |
67 |
68 |
69 |
73 |
74 |
79 |
80 |
81 |
85 |
86 |
87 |
88 |
89 |
90 |
91 |
92 |
93 |
94 |
95 |
96 |
99 |
104 |
105 |
106 |
109 |
110 |
111 |
112 |
--------------------------------------------------------------------------------
/app/src/main/java/dev/akampf/fileshare/Constants.kt:
--------------------------------------------------------------------------------
1 | package dev.akampf.fileshare
2 |
3 |
4 | // This file contains app global constants.
5 |
6 | const val NOTIFICATION_CHANNEL_ID_WIFI_DIRECT_CONNECTION: String = "notification_channel_wifi_direct_connection"
--------------------------------------------------------------------------------
/app/src/main/java/dev/akampf/fileshare/LocationModeChangedBroadcastReceiver.kt:
--------------------------------------------------------------------------------
1 | package dev.akampf.fileshare
2 |
3 | import android.content.BroadcastReceiver
4 | import android.content.Context
5 | import android.content.Intent
6 | import android.location.LocationManager
7 | import android.os.Build
8 | import android.util.Log
9 | import androidx.core.content.ContextCompat.getSystemService
10 | import androidx.core.location.LocationManagerCompat
11 | import kotlin.time.ExperimentalTime
12 |
13 |
14 | private const val LOG_TAG: String = "LocationModeBrdcastRcvr"
15 |
16 | // TODO (check again new documentation!) from documentation it is not clear if it only notifies for on/off or for other mode changes, too
17 | @ExperimentalTime
18 | class LocationModeChangedBroadcastReceiver : BroadcastReceiver() {
19 |
20 | override fun onReceive(context: Context, intent: Intent): Unit {
21 | // This method is called when the BroadcastReceiver is receiving an Intent broadcast.
22 | when(intent.action) {
23 | LocationManager.MODE_CHANGED_ACTION -> {
24 |
25 | val locationManager: LocationManager = getSystemService(context, LocationManager::class.java) as LocationManager
26 | var locationModeEnabled: Boolean = LocationManagerCompat.isLocationEnabled(locationManager)
27 |
28 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
29 | // change to the value provided by the intent extra, falling back to the previously determined value if no extra found with that
30 | // name
31 | // This gets the mode race condition free as we were notified of the mode changed action, so it cannot be different now compared
32 | // to the value that triggered the intent. If it is changed again, we should get notified again in this broadcast receiver and
33 | // thus will not miss a switch this way!
34 | locationModeEnabled = intent.getBooleanExtra(LocationManager.EXTRA_LOCATION_ENABLED, locationModeEnabled)
35 | }
36 |
37 | Log.i(LOG_TAG, "Location mode changed, activation state: $locationModeEnabled")
38 | (context as MainActivity).locationModeEnabled = locationModeEnabled
39 | }
40 |
41 | // different action or action is null, this should not happen as we did not register for other actions in the intent filter
42 | else -> {}
43 | }
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/app/src/main/java/dev/akampf/fileshare/MainActivity.kt:
--------------------------------------------------------------------------------
1 | package dev.akampf.fileshare
2 |
3 | // `import kotlinx.android.synthetic.main.activity_main.*` is used to access views directly by their id as the variable name
4 | // without `findViewByID()`, uses `kotlin-android-extensions` which does lookup and caching for us
5 | import android.Manifest
6 | import android.app.Activity
7 | import android.content.*
8 | import android.content.pm.PackageManager
9 | import android.database.Cursor
10 | import android.location.LocationManager
11 | import android.net.Uri
12 | import android.net.wifi.p2p.*
13 | import android.os.Build
14 | import android.os.Bundle
15 | import android.os.IBinder
16 | import android.provider.OpenableColumns
17 | import android.util.Log
18 | import android.view.View
19 | import androidx.appcompat.app.AppCompatActivity
20 | import androidx.core.app.ActivityCompat
21 | import androidx.core.content.ContextCompat
22 | import androidx.core.content.FileProvider
23 | import androidx.core.location.LocationManagerCompat
24 | import androidx.localbroadcastmanager.content.LocalBroadcastManager
25 | import com.google.android.material.snackbar.Snackbar
26 | import kotlinx.android.synthetic.main.activity_main.*
27 | import java.io.File
28 | import java.net.InetAddress
29 | import kotlin.time.ExperimentalTime
30 |
31 | // be app unique or what are the rules? use enum?
32 | private const val OPEN_FILE_WITH_FILE_CHOOSER_REQUEST_CODE: Int = 42
33 | private const val ACCESS_FINE_LOCATION_PERMISSION_REQUEST_CODE: Int = 52
34 |
35 |
36 | private const val ACCESS_FINE_LOCATION_PERMISSION: String = Manifest.permission.ACCESS_FINE_LOCATION
37 |
38 | private const val LOG_TAG: String = "WiFiDirectMainActivity"
39 |
40 |
41 | @ExperimentalTime
42 | class MainActivity : AppCompatActivity(), WiFiDirectDeviceFragment.OnListFragmentInteractionListener {
43 |
44 |
45 | private lateinit var receiveIpAddressService: WiFiDirectBackgroundService
46 | private var receiveIpAddressServiceIsBound: Boolean = false
47 |
48 |
49 | // todo: transfer bind from broadcastreceiver to activity onResume or onStart
50 | /** Defines callbacks for service binding, passed to bindService() */
51 | val receiveIpAddressServiceConnection = object : ServiceConnection {
52 |
53 | override fun onServiceConnected(componentNameOfServic: ComponentName, service: IBinder) {
54 | // We've bound to the Service, cast the IBinder and get the Service instance
55 | Log.d(LOG_TAG, "onServiceConnected callback in MainActivity called for $componentNameOfServic")
56 | val binder = service as WiFiDirectBackgroundService.LocalBinder
57 | // receiveIpAddressService = binder.getService()
58 | receiveIpAddressServiceIsBound = true
59 | }
60 |
61 | // seems to only get called on unexpected lost connection to service like the service crashing
62 | // and not on normal unbind or stop, documentation is vague... :/
63 | // https://developer.android.com/reference/android/content/ServiceConnection.html#onServiceDisconnected(android.content.ComponentName)
64 | override fun onServiceDisconnected(componentNameOfService: ComponentName) {
65 | Log.e(LOG_TAG, "Service Connection to $componentNameOfService lost!")
66 | receiveIpAddressServiceIsBound = false
67 | }
68 | }
69 |
70 |
71 | // finish service binding here, onstart etc, see android website example
72 | // https://developer.android.com/guide/components/bound-services#Binder
73 | // currently bound to in broadcast receiver
74 |
75 |
76 | // TODO register callback when connection to system WiFi Direct manager gets lost and handle appropriately
77 | private val wiFiDirectManager: WifiP2pManager? by lazy(LazyThreadSafetyMode.NONE) {
78 | ContextCompat.getSystemService(this, WifiP2pManager::class.java)
79 | }
80 |
81 | // TODO clear this and related variables on disconnect to make tracking state easier?
82 | var wiFiDirectGroupInfo: WifiP2pInfo? = null
83 |
84 | var connectedClientWiFiDirectIpAddress: InetAddress? = null
85 |
86 | // used to receive the ip address of the other device from the service and save it
87 | // TODO should be moved to repository class or some kind of data storage outside of activity
88 | private val ipAddressBroadcastReceiver: BroadcastReceiver = object : BroadcastReceiver() {
89 | override fun onReceive(context: Context?, intent: Intent?) {
90 | intent ?: TODO("received local broadcast for ip address without intent, this shouldn't happen")
91 | intent.action ?: TODO("no action specified in local broadcast")
92 | val ipAddressFromOtherDevice: InetAddress =
93 | intent.getSerializableExtra(WiFiDirectBackgroundService.EXTRA_IP_ADDRESS_OF_CONNECTED_DEVICE) as? InetAddress
94 | ?: TODO("no extra with ip address specified in local broadcast or couldn't deserialize inetaddress")
95 | Log.d(LOG_TAG, "setting ip address of other device $ipAddressFromOtherDevice in main activity")
96 | this@MainActivity.connectedClientWiFiDirectIpAddress = ipAddressFromOtherDevice
97 |
98 | }
99 | }
100 |
101 | private val newFileReceivedBroadcastReceiver: BroadcastReceiver = object : BroadcastReceiver() {
102 | override fun onReceive(context: Context?, intent: Intent?) {
103 | Log.d(LOG_TAG, "onReceive of new file received broadcast receiver")
104 | intent ?: TODO("received local broadcast for newly received file without intent, this shouldn't happen")
105 | intent.action ?: TODO("received local broadcast for newly received file without intent action")
106 | val absolutePathToNewlyReceivedFile: String = intent.getSerializableExtra(WiFiDirectBackgroundService.EXTRA_ABSOLUTE_PATH_TO_NEWLY_RECEIVED_FILE_FROM_CONNECTED_DEVICE) as? String
107 | ?: TODO("no extra with absolute path to newly received file as string in local broadcast or couldn't deserialize it")
108 |
109 |
110 | Log.d(LOG_TAG, "File was written to $absolutePathToNewlyReceivedFile")
111 | wiFi_direct_status_text_view.text = "File received: $absolutePathToNewlyReceivedFile"
112 | displayFileInAppropriateNewActivityIfPossible(absolutePathToNewlyReceivedFile)
113 |
114 | }
115 | }
116 |
117 | private fun displayFileInAppropriateNewActivityIfPossible(absolutePathToFile: String) {
118 |
119 |
120 | val uriToFile: Uri = Uri.parse("file://$absolutePathToFile")
121 |
122 |
123 |
124 | // TODO this is ugly, extract method as general helper (for a context, because of content provider) or just globally
125 | // TODO change to display info with other method cause this only works with the content:// scheme and not file:// urls!
126 | dumpContentUriMetaData(uriToFile)
127 |
128 | val viewIntent: Intent = Intent(Intent.ACTION_VIEW)
129 |
130 | // use content provider for content:// uri scheme used in newer versions for modern working secure sharing of files with other apps,
131 | // but not all apps might support those instead of file:// uris, so still use them for older versions where they work for greater
132 | // compatibility
133 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
134 | // needed for app displaying the file having the temporary access to read from this uri, either uri must be put in data of intent
135 | // or `Context.grantUriPermission` must be called for the target package
136 | // explain in comments why this is needed / what exactly is needed more clearly
137 | viewIntent.flags = Intent.FLAG_GRANT_READ_URI_PERMISSION
138 |
139 | // the authority argument of `getUriForFile` must be the same as the authority of the file provider defined in the AndroidManifest!
140 | // should extract authority in global variable or resource to reuse in manifest and code
141 | val fileProviderUri =
142 | FileProvider.getUriForFile(this, BuildConfig.APPLICATION_ID + ".provider", File(absolutePathToFile))
143 |
144 | // normalizing the uri to match android best practices for schemes: makes the scheme component lowercase
145 | viewIntent.setDataAndNormalize(fileProviderUri)
146 | } else {
147 | // normalizing the uri to match android case-sensitive matching for schemes: makes the scheme component lowercase
148 | viewIntent.setDataAndNormalize(uriToFile)
149 | }
150 |
151 | try {
152 | startActivity(viewIntent)
153 | } catch (e: ActivityNotFoundException) {
154 | Log.w(ReceiveFileOrGetIpAddressFromOtherDeviceAsyncTask.LOG_TAG, "No installed app supports viewing this content!", e)
155 | Snackbar.make(
156 | root_coordinator_layout,// could also be that the other device has not connected to us yet but the wifi direct connection is fine
157 | getString(R.string.could_not_find_activity_to_handle_viewing_content),
158 | Snackbar.LENGTH_LONG
159 | ).show()
160 | }
161 |
162 | }
163 |
164 | private val wiFiDirectIntentFilter: IntentFilter = IntentFilter().apply {
165 | // Indicates a change in the Wi-Fi P2P status.
166 | addAction(WifiP2pManager.WIFI_P2P_STATE_CHANGED_ACTION)
167 | // Indicates a change in the list of available peers.
168 | addAction(WifiP2pManager.WIFI_P2P_PEERS_CHANGED_ACTION)
169 | // Indicates the state of Wi-Fi P2P connectivity has changed.
170 | addAction(WifiP2pManager.WIFI_P2P_CONNECTION_CHANGED_ACTION)
171 | // Indicates this device's details have changed.
172 | addAction(WifiP2pManager.WIFI_P2P_THIS_DEVICE_CHANGED_ACTION)
173 | }
174 |
175 | private var wiFiDirectChannel: WifiP2pManager.Channel? = null
176 | private var wiFiDirectBroadcastReceiver: BroadcastReceiver? = null
177 |
178 |
179 | // TODO just enable wifi etc ourselves? location mode and wifi disabled snackbars don't display both
180 | // https://developer.android.com/about/versions/10/privacy/changes#enable-disable-wifi
181 | // TODO add new ui widgets per missing permission/activation with option to change and only display "unavailable because:" or nothing in first line
182 |
183 | // This is set to true when mWiFiDirectEnable is first set to true or false by the wifi direct broadcast receiver. Then we know the value
184 | // in it is not the default false value but the value accurately describing the wifi direct state
185 | var wiFiDirectStatusInitialized: Boolean = false
186 |
187 | // TODO can permission be revoked without onResume being called again, do we need to check permissions after checking in onResume that
188 | // we have the permission?
189 | // how to handle if later wifi is deactivated when we connect to a device or are already connected etc?
190 | // can we distinguish disabled and unavailable? is it unavailable on supported devices sometimes even?
191 | var wiFiDirectEnabled: Boolean = false
192 | set(wiFiDirectEnabled) {
193 |
194 |
195 | // Only continue to execute something if the value changed compared to the old value OR it is the first time a valid value is set by
196 | // the wifi direct broadcast receiver (could also be set from false to false, but the previous value
197 | // only was the default value false!)
198 | // => if the value is the same and it was already initialized => no *real* change and skip rest of code reacting to a change
199 | //
200 | // If the value did not change, but is set, it is probably just spam from the wifi direct broadcast receiver reporting it multiple
201 | // times
202 | if ((wiFiDirectEnabled == this.wiFiDirectEnabled) && wiFiDirectStatusInitialized) {
203 | return
204 | }
205 |
206 | if (wiFiDirectEnabled) {
207 | wiFi_direct_status_text_view.text = getString(R.string.wifi_direct_status_enabled)
208 | } else {
209 | wiFi_direct_status_text_view.text = getString(R.string.wifi_direct_status_disabled)
210 | Snackbar.make(
211 | root_coordinator_layout,
212 | getString(R.string.wifi_direct_disabled_message),
213 | Snackbar.LENGTH_INDEFINITE
214 | ).show()
215 | }
216 | if (wiFiDirectEnabled && fineLocationPermissionGranted) {
217 | // TODO maybe request again if not granted? think about at what times request is appropriate or just make button after first request
218 | discoverWiFiDirectPeers()
219 | }
220 |
221 | field = wiFiDirectEnabled
222 | }
223 |
224 |
225 | // TODO we should also check if location services are enabled by the user and prompt for that if not the case
226 | // TODO luckily setter is not called on initialization with false, since this could be wrong (permission already granted), but still
227 | // initialization with false does not really seem clean, maybe initialize with null if not accessed except in setter (no null safety)?
228 | private var fineLocationPermissionGranted: Boolean = false
229 | set(fineLocationPermissionGranted_new_value) {
230 |
231 | if (fineLocationPermissionGranted_new_value) {
232 | if (wiFiDirectEnabled) {
233 |
234 | discoverWiFiDirectPeers()
235 | }
236 | // TODO notify that wifi needs to be enabled after location permission was granted or just enable it automatically
237 | // and notify about it?
238 | } else {
239 | // TODO when (first) requesting permission, display sth like "requesting permission" and not "denied", cause that seems harsh and
240 | // users might see this behind the permission request dialog, "requesting" seems more appropriate to convey the state, even if
241 | // denied (from the system) is also technically correct
242 | wiFi_direct_status_text_view.text = getString(R.string.wifi_direct_location_permission_denied)
243 | }
244 | // set field to new value at the end so we can use the old value before for comparing if it changed or was the same before this setter
245 | // was called / the property was set
246 | field = fineLocationPermissionGranted_new_value
247 | }
248 |
249 | private val locationModeChangedBroadcastReceiver: LocationModeChangedBroadcastReceiver = LocationModeChangedBroadcastReceiver()
250 | private val locationModeChangedIntentFilter: IntentFilter = IntentFilter(LocationManager.MODE_CHANGED_ACTION)
251 |
252 | var locationModeEnabled: Boolean = false
253 | set(newValueLocationModeEnabled) {
254 | Log.v(LOG_TAG, "Location mode set to state: $newValueLocationModeEnabled")
255 | if (!newValueLocationModeEnabled) {
256 | Snackbar.make(
257 | root_coordinator_layout,
258 | getString(R.string.wifi_direct_location_mode_disabled),
259 | Snackbar.LENGTH_INDEFINITE
260 | ).show()
261 | }
262 | field = newValueLocationModeEnabled
263 | }
264 |
265 |
266 | lateinit var wiFiDirectDeviceFragment: WiFiDirectDeviceFragment
267 |
268 | val wiFiDirectPeers: MutableList = mutableListOf()
269 |
270 | fun notifyWiFiDirectPeerListDiscoveryFinished(discoveredPeers: WifiP2pDeviceList): Unit {
271 | Log.i(LOG_TAG, "Discovered WiFi Direct peers:\n" +
272 | discoveredPeers.deviceList.joinToString(separator = "\n") { device -> "Device name: ${device.deviceName}" })
273 |
274 | // sort or even filter by device type: https://www.wifi-libre.com/img/members/3/Wi-Fi_Simple_Configuration_Technical_Specification_v2_0_5.pdf
275 | // maybe use discovery to detect on which device the app is actually running
276 |
277 | // why not work directly with WifiP2pDeviceList ? current code from:
278 | // https://developer.android.com/training/connect-devices-wirelessly/wifi-direct#fetch
279 | val refreshedPeers = discoveredPeers.deviceList
280 | if (refreshedPeers != wiFiDirectPeers) {
281 | wiFiDirectPeers.clear()
282 | wiFiDirectPeers.addAll(refreshedPeers)
283 |
284 | // TODO move peer list management to fragment to make management of ui changes easier?
285 | // or at least use https://developer.android.com/guide/components/fragments#CommunicatingWithActivity or
286 | // https://developer.android.com/reference/kotlin/androidx/fragment/app/FragmentManager.html to interact with fragment?
287 | // Tell the RecyclerViewAdapter that is backed by this data that it changed so it can update the view
288 | // this could be replaced ideally by using livedata lists for the devices list or by using:
289 | // https://developer.android.com/reference/androidx/recyclerview/widget/ListAdapter
290 | wiFiDirectDeviceFragment.recyclerViewAdapter.notifyDataSetChanged()
291 | }
292 |
293 | if (wiFiDirectPeers.isEmpty()) {
294 | Log.i(LOG_TAG, "No WiFi Direct devices found in current scan")
295 | }
296 | }
297 |
298 |
299 | // the RecyclerView in the fragment calls this method when a view in it was clicked
300 | override fun onListFragmentInteraction(wiFiDirectDevice: WifiP2pDevice): Unit {
301 | wiFiDirectDevice.let { clickedDeviceItem ->
302 | Log.i(LOG_TAG, "$clickedDeviceItem\n has been clicked")
303 | connectToWiFiDirectDevice(wiFiDirectDevice)
304 | }
305 | }
306 |
307 | private fun connectToWiFiDirectDevice(wiFiDirectDeviceToConnectTo: WifiP2pDevice): Unit {
308 |
309 | val wiFiDirectConnectionConfig = WifiP2pConfig().apply {
310 | deviceAddress = wiFiDirectDeviceToConnectTo.deviceAddress
311 | // use other wps setup method if PBC not supported or is PBC always supported by laptops, tablets, phones, etc?
312 | // see if WiFi Direct spec says what must be supported
313 | //
314 | // WpsInfo.PBC is default value
315 | // wps.setup = here_is_the_mode_to_use
316 | }
317 |
318 | // initiates WiFi Direct group negotiation with target or invite device to existing group where this device is already part of (because
319 | // it has joined or has created the group itself)
320 | // TODO at this point another device was found, so the manager was clearly initiated, should we just assume that, assert that or catch
321 | // the case that it was null and display an error / handle it appropriately?
322 | wiFiDirectChannel.also {
323 | // TODO here https://developer.android.com/training/connect-devices-wirelessly/wifi-direct#connect it is done without ?. (safe call) /
324 | // being safe that mChannel is null and ide also does not complain that mChannel is of nullable type, even though it
325 | // complains about that when initializing, even though there the manager should be not null after initialization the channel with it
326 | // and only doing sth with the channel if it is not null and using the manager there
327 |
328 | // TODO linter says this and other methods called need permission check, when / how often do we need to check these permissions?
329 | wiFiDirectManager?.connect(wiFiDirectChannel, wiFiDirectConnectionConfig, object : WifiP2pManager.ActionListener {
330 |
331 | override fun onSuccess(): Unit {
332 | Log.d(LOG_TAG, "Initiation of connection to $wiFiDirectDeviceToConnectTo succeeded")
333 | // We get notified by broadcast when the connection is really there
334 | }
335 |
336 | override fun onFailure(reason: Int): Unit {
337 | val failureReason: String = getFailureReasonForWiFiDirectActionListener(reason)
338 | Log.i(LOG_TAG, "Initiation of connection to $wiFiDirectDeviceToConnectTo failed: $failureReason")
339 | // Make text for failure reason suitably short for Snackbar or display otherwise in the future
340 | Snackbar.make(
341 | root_coordinator_layout,
342 | getString(
343 | R.string.wifi_direct_connection_initiation_failed,
344 | wiFiDirectDeviceToConnectTo.deviceName,
345 | wiFiDirectDeviceToConnectTo.deviceAddress,
346 | failureReason
347 | ),
348 | Snackbar.LENGTH_INDEFINITE
349 | ).show()
350 | }
351 | })
352 | }
353 |
354 | }
355 |
356 | /**
357 | * If MainActivity *currently* has specified android permission, result can change at any time.
358 | *
359 | * @param androidManifestPermission String in the Android.manifest.* namespace.
360 | * @return if permission is currently available
361 | */
362 | private fun havePermissionCurrently(androidManifestPermission: String): Boolean {
363 | return ContextCompat.checkSelfPermission(this, androidManifestPermission) == PackageManager.PERMISSION_GRANTED
364 |
365 | }
366 |
367 |
368 | private fun requestFineLocationPermissionForWiFiDirect(): Unit {
369 |
370 | // Should we show an explanation? (this is true when the permission was denied in the past)
371 | if (ActivityCompat.shouldShowRequestPermissionRationale(
372 | this,
373 | ACCESS_FINE_LOCATION_PERMISSION
374 | )
375 | ) {
376 | Log.i(LOG_TAG, "Fine Location permission was denied in the past! TODO: show an explanation before retrying")
377 | // TODO: Show an explanation to the user *asynchronously* -- don't block
378 | // this thread waiting for the user's response! After the user
379 | // sees the explanation, try again to request the permission.
380 |
381 | // TODO at least on android 10 device and android R preview emulator, this loops after one denied request, so show rationale
382 | // and then request again but also
383 | // allow to cancel so that no request is made after that and the permission request loop exits.
384 | // https://developer.android.com/preview/privacy/permissions#dialog-visibility
385 | // this will be removed and put in the callback of the explanation window/popup/whatever
386 | ActivityCompat.requestPermissions(
387 | this,
388 | arrayOf(ACCESS_FINE_LOCATION_PERMISSION),
389 | ACCESS_FINE_LOCATION_PERMISSION_REQUEST_CODE
390 | )
391 |
392 | } else {
393 | // TODO show rationale for location for WiFi Direct every time, even the first time
394 | Log.i(
395 | LOG_TAG, "Fine Location permission was not denied in the past, request permission directly " +
396 | "without showing rationale. TODO maybe show rationale for location for WiFi Direct every time"
397 | )
398 | // No explanation needed, we can request the permission.
399 | ActivityCompat.requestPermissions(
400 | this,
401 | arrayOf(ACCESS_FINE_LOCATION_PERMISSION),
402 | ACCESS_FINE_LOCATION_PERMISSION_REQUEST_CODE
403 | )
404 | // The callback method `onRequestPermissionsResult` gets the result of the request providing the same request code as used here.
405 | }
406 | }
407 |
408 | private fun initializeWiFiDirect(): Unit {
409 | // Registers the application with the Wi-Fi Direct framework to do any further operations.
410 |
411 | // TODO: do we need to do all of this even when only onResume is executed and not onCreate? according to examples, this is not the case
412 | // this only needs `android.permission.ACCESS_WIFI_STATE` so we wan safely call it without location permission, cause
413 | // `android.permission.ACCESS_WIFI_STATE` was granted at install time
414 |
415 | // TODO set listener to be notified of loss of framework communication?
416 | wiFiDirectChannel = wiFiDirectManager?.initialize(this, mainLooper, null)
417 | wiFiDirectChannel?.let { channel ->
418 | // If wiFiDirectChannel was not null, we are already sure manager was not null, too, because of the mWiFiDirectManager?.initialize() call
419 | // above only being executed when wiFiDirectManager was not null. So we cast it to not optional type with the
420 | // characters !! (assert non-null).
421 | wiFiDirectBroadcastReceiver = WiFiDirectBroadcastReceiver(wiFiDirectManager!!, channel, this)
422 |
423 | }
424 | }
425 |
426 | // result callback from requesting dangerous permission access from the system, e.g. Location for WiFi Direct
427 | override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray): Unit {
428 | when (requestCode) {
429 | ACCESS_FINE_LOCATION_PERMISSION_REQUEST_CODE -> {
430 | // If request is cancelled, the result arrays are empty.
431 | if (grantResults.getOrNull(0) == PackageManager.PERMISSION_GRANTED) {
432 | // permission was granted, yay! Do the
433 | // permission related task you need to do.
434 | fineLocationPermissionGranted = true
435 |
436 |
437 | } else {
438 | Log.w(LOG_TAG, "Fine Location permission denied, cannot use WiFi Direct! TODO: disable all functionality dependent on that")
439 | // TODO permission denied, boo! Disable the functionality that depends on this permission.
440 | }
441 | return
442 | }
443 |
444 | // Add other 'when' cases here to check if there are other
445 | // permissions this app might have requested.
446 | else -> {
447 | // Ignore all other requests.
448 | }
449 | }
450 | }
451 |
452 |
453 | private fun discoverWiFiDirectPeers(): Unit {
454 |
455 | // This only initiates the discovery, this method immediately returns.
456 | // The discovery remains active in the background until a connection is initiated or a p2p group is formed.
457 | wiFiDirectManager?.discoverPeers(wiFiDirectChannel, object : WifiP2pManager.ActionListener {
458 |
459 | // success initiating the scan for peers
460 | override fun onSuccess(): Unit {
461 | Log.i(LOG_TAG, "Initiating peer discovery successful")
462 | // In the future, if the discovery process succeeds and detects peers, the system broadcasts the
463 | // WIFI_P2P_PEERS_CHANGED_ACTION intent, which we can listen for in a broadcast receiver to then obtain a list of peers.
464 | }
465 |
466 | // failed to initiate the scan for peers
467 | // This currently happens with "framework busy" error after starting app and allowing location
468 | // permission for the first time
469 | override fun onFailure(reasonCode: Int): Unit {
470 | val failureReason: String = getFailureReasonForWiFiDirectActionListener(reasonCode)
471 | Log.w(LOG_TAG, "Initiating peer discovery failed: $failureReason")
472 | // Display this error in this way? make reason short enough for snackbar
473 | showSnackbarLengthIndefinite("Initiating peer discovery failed: $failureReason")
474 | }
475 | })
476 | }
477 |
478 |
479 | override fun onCreate(savedInstanceState: Bundle?): Unit {
480 | Log.d(LOG_TAG, "onCreate starting...")
481 | super.onCreate(savedInstanceState)
482 | setContentView(R.layout.activity_main)
483 | initializeWiFiDirect()
484 |
485 | Log.d(LOG_TAG, "registering local broadcast receiver for receiving ip address and newly received file of other device from service...")
486 | // do in onCreate for now to receive it in the background too, to act upon it later, bad architecture to receive in activity, i know :(
487 | IntentFilter(WiFiDirectBackgroundService.ACTION_REPORT_IP_ADDRESS_OF_CONNECTED_DEVICE).also { receiveIpAddressIntentFilter ->
488 | LocalBroadcastManager.getInstance(this).registerReceiver(ipAddressBroadcastReceiver, receiveIpAddressIntentFilter)
489 | }
490 | IntentFilter(WiFiDirectBackgroundService.ACTION_REPORT_NEW_FILE_RECEIVED_FROM_CONNECTED_DEVICE).also { newFileReceivedIntentFilter ->
491 | LocalBroadcastManager.getInstance(this).registerReceiver(newFileReceivedBroadcastReceiver, newFileReceivedIntentFilter)
492 |
493 | }
494 |
495 | }
496 |
497 | override fun onStart() {
498 | super.onStart()
499 |
500 | // TODO consider transferring registering broadcast receiver to app or service context to notify user of disabling location
501 | // or permissions needed
502 | // while transferring data and give explicit error message (if not available in failing transfer) and offer to activate it again
503 | // and maybe even resume the transfer then
504 | // https://developer.android.com/guide/components/broadcasts#context-registered-receivers (search for application context)
505 |
506 |
507 | // The broadcast receiver does not fire for changes while not registered (here: while app was inactive, since we unregister in onStop)
508 | // so we have to query the value once when app (or service depending on it) gets active
509 | val locationManager: LocationManager = ContextCompat.getSystemService(this, LocationManager::class.java) as LocationManager
510 | val currentLocationModeState: Boolean = LocationManagerCompat.isLocationEnabled(locationManager)
511 | Log.i(LOG_TAG, "Location mode queried onResume, state: $currentLocationModeState")
512 | locationModeEnabled = currentLocationModeState
513 |
514 | Log.v(LOG_TAG, "registering location mode changed broadcast receiver... : $locationModeChangedBroadcastReceiver")
515 | registerReceiver(locationModeChangedBroadcastReceiver, locationModeChangedIntentFilter)
516 |
517 |
518 | Log.v(LOG_TAG, "registering wifi direct broadcast receiver... : $wiFiDirectBroadcastReceiver")
519 | registerReceiver(wiFiDirectBroadcastReceiver, wiFiDirectIntentFilter)
520 |
521 |
522 | val haveFineLocationPermission = havePermissionCurrently(ACCESS_FINE_LOCATION_PERMISSION)
523 |
524 | if (haveFineLocationPermission) {
525 | Log.i(LOG_TAG, "We already have Fine Location permission")
526 | fineLocationPermissionGranted = true
527 | } else {
528 | fineLocationPermissionGranted = false
529 | Log.i(LOG_TAG, "We do not have Fine Location permission, requesting...")
530 | // TODO permission is requested again after being denied because onResume is executed again (after the permission dialog where
531 | // deny was pressed is not in foreground anymore), leave for now and change to manually
532 | // requesting / explaining how to grant in settings when denied once or twice.
533 | // Move all code dependant on this state to setter?
534 | requestFineLocationPermissionForWiFiDirect()
535 | }
536 |
537 | // start service before binding to it, so it stays active even after unbinding
538 | // when exiting the app
539 | // We do this in the service, too in bind and rebind, can we drop it here?
540 | Log.d(LOG_TAG, "Starting new receive ip address and file service, still testing...")
541 | val startServiceIntent: Intent = Intent(this, WiFiDirectBackgroundService::class.java)
542 | ContextCompat.startForegroundService(this, startServiceIntent)
543 |
544 | Intent(this, WiFiDirectBackgroundService::class.java).also { intent ->
545 | Log.i(LOG_TAG, "Trying to bindService to ${WiFiDirectBackgroundService::class.simpleName}...")
546 | bindService(intent, receiveIpAddressServiceConnection, Context.BIND_AUTO_CREATE).also { bindRequestSucceeded ->
547 | Log.i(
548 | LOG_TAG,
549 | "bind request to ${WiFiDirectBackgroundService::class.simpleName} success status: $bindRequestSucceeded"
550 | )
551 | }
552 | }
553 | }
554 |
555 |
556 | // called when activity in visible AND active in the foreground
557 | // (in multi window it is possible to be visible but not active in the foreground)
558 | //
559 | // On platform versions prior to Build.VERSION_CODES.Q (Android 10, API 29) this is also a good place to try to open exclusive-access
560 | // devices or to get access to singleton resources. Starting with Build.VERSION_CODES.Q there can be multiple resumed activities in
561 | // the system simultaneously, so onTopResumedActivityChanged(boolean) should be used for that purpose instead.
562 | override fun onResume(): Unit {
563 | Log.d(LOG_TAG, "onResume starting...")
564 | super.onResume()
565 | }
566 |
567 | // Called when activity not active in the foreground anymore, but could still be visible (possible in multi window)
568 | //
569 | // Starting with Honeycomb, an application is not in the killable state until its onStop() has returned.
570 | // This allows an application to safely wait until onStop() to save persistent state.
571 | // Keep in mind that under extreme memory pressure the system can kill the application process at any time!
572 | //
573 | // Implementations of this method must be very quick because the next activity will not be resumed until this method returns.
574 | // This is also a good place to stop things that consume a noticeable amount of CPU in order to make the switch to the next activity as fast as possible.
575 | override fun onPause(): Unit {
576 | Log.d(LOG_TAG, "onPause starting...")
577 | super.onPause()
578 | }
579 |
580 | override fun onStop() {
581 | Log.d(LOG_TAG, "onStop starting...")
582 |
583 | Log.v(LOG_TAG, "unregistering Location Mode changed Broadcast Receiver... : $locationModeChangedBroadcastReceiver")
584 | unregisterReceiver(locationModeChangedBroadcastReceiver)
585 |
586 | if (wiFiDirectBroadcastReceiver != null) {
587 | Log.v(LOG_TAG, "unregistering wifi direct broadcast receiver... : $wiFiDirectBroadcastReceiver")
588 | unregisterReceiver(wiFiDirectBroadcastReceiver)
589 | } else {
590 | Log.w(LOG_TAG, "Did not unregister WiFi Direct Broadcast Receiver in onPause because it was null!")
591 | }
592 | // set to false here so when resuming activity again, a change in wifi direct activation state is handled as if it changed (at the
593 | // moment only used for actively displaying message that it is disabled)
594 | wiFiDirectStatusInitialized = false
595 |
596 |
597 | // unbind from service to indicate app is not in the foreground anymore
598 | // service can decide when to exit itself based on that
599 | if (receiveIpAddressServiceIsBound) {
600 | unbindService(receiveIpAddressServiceConnection)
601 | receiveIpAddressServiceIsBound = false
602 | }
603 | super.onStop()
604 | }
605 |
606 |
607 | override fun onDestroy() {
608 | Log.d(LOG_TAG, "unregistering local broadcast receiver for receiving ip address or new file of other device from service...")
609 | LocalBroadcastManager.getInstance(this).unregisterReceiver(ipAddressBroadcastReceiver)
610 | LocalBroadcastManager.getInstance(this).unregisterReceiver(newFileReceivedBroadcastReceiver)
611 | super.onDestroy()
612 | }
613 |
614 | // consider using ACTION_GET_CONTENT because we only need a copy and not permanent access to the file if it changes and/or modify the file and write it back
615 | // If sending big file, how long is access guaranteed? copy to own directory first if space available? better for retrying later? better solution?
616 | // https://developer.android.com/guide/topics/providers/document-provider#client
617 | // Fires an intent to spin up the "file chooser" UI and select a file.
618 | private fun getOpenableFilePickedByUser(): Unit {
619 |
620 | // ACTION_OPEN_DOCUMENT is the intent to choose a file via the system's file
621 | // browser.
622 | val intent: Intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
623 | // Filter to only show results that can be "opened", such as a
624 | // file (as opposed to a list of contacts or timezones)
625 | // Without this we could not simply get a byte stream from the content to share to read its data, thus requiring special
626 | // handling to transfer its contents. But this should be possible in the future, too. Like sending contacts. #enhancement
627 | addCategory(Intent.CATEGORY_OPENABLE)
628 |
629 | // Filter to show only images, using the image MIME data type.
630 | // If one wanted to search for ogg vorbis files, the type would be "audio/ogg".
631 | // To search for all documents available via installed storage providers,
632 | // it would be "*/*".
633 | type = "*/*"
634 | }
635 |
636 | // result is delivered in `onActivityResult` callback
637 | try {
638 | startActivityForResult(intent, OPEN_FILE_WITH_FILE_CHOOSER_REQUEST_CODE)
639 | } catch (e: ActivityNotFoundException) {
640 | Log.e(
641 | LOG_TAG, "Could not open file chooser activity to choose file, activity not found that could handle our " +
642 | "request! This should be present in every system and thus this error should never occur!", e
643 | )
644 | }
645 | }
646 |
647 |
648 | fun onClickedOpenFileButton(view: View): Unit {
649 | // TODO we should really check if we are connected to a device currently and not only if info about a connected device was set sometime
650 | // currently opening file chooser is possible and then snackbar says connection to device lost
651 | // check ip address of other device available (if other device is not group owner) and reset those info on connection lost
652 | Log.d(LOG_TAG, "open file button clicked")
653 | if (wiFiDirectGroupInfo != null) {
654 | // result is delivered in `onActivityResult` callback
655 | getOpenableFilePickedByUser()
656 | } else {
657 | Snackbar.make(
658 | root_coordinator_layout,
659 | getString(R.string.wifi_direct_please_connect_first),
660 | Snackbar.LENGTH_LONG
661 | ).show()
662 | }
663 |
664 | }
665 |
666 | fun onClickedDisconnectButton(view: View): Unit {
667 | disconnectWiFiDirectConnection()
668 | }
669 |
670 | private fun disconnectWiFiDirectConnection(): Unit {
671 | if (wiFiDirectManager == null || wiFiDirectChannel == null) {
672 | Log.w(LOG_TAG, "Could not request disconnect because WiFi Direct Manager or Channel are null")
673 | showSnackbarLengthIndefinite("Could not request disconnect because WiFi Direct Manager or Channel are null")
674 | return
675 | }
676 | wiFiDirectManager?.requestGroupInfo(wiFiDirectChannel) { wifiDirectGroup: WifiP2pGroup? ->
677 | Log.d(LOG_TAG, "Current WiFi Direct Group: $wifiDirectGroup")
678 | if (wifiDirectGroup == null) {
679 | showSnackbarLengthIndefinite("Could not request disconnect because current WiFi Direct Group is null")
680 | Log.w(LOG_TAG, "Could not request disconnect because current WiFi Direct Group is null")
681 | return@requestGroupInfo
682 | }
683 | if (wiFiDirectManager == null || wiFiDirectChannel == null) {
684 | Log.w(
685 | LOG_TAG,
686 | "Could not request disconnect because WiFi Direct Manager or Channel after having received WiFi Direct group info"
687 | )
688 | showSnackbarLengthIndefinite("Could not request disconnect because WiFi Direct Manager or Channel after having received WiFi Direct group info")
689 | return@requestGroupInfo
690 | } else {
691 | wiFiDirectManager?.removeGroup(wiFiDirectChannel, object : WifiP2pManager.ActionListener {
692 | override fun onSuccess() {
693 | Log.i(LOG_TAG, "Successfully disconnected from group: $wifiDirectGroup")
694 | showSnackbarLengthIndefinite("Successfully disconnected from: $wifiDirectGroup")
695 | // TODO stop the android service listening for requests and ip address, too
696 | }
697 |
698 | override fun onFailure(reason: Int) {
699 | val failureReason: String = getFailureReasonForWiFiDirectActionListener(reason)
700 | Log.w(LOG_TAG, "Error when trying to disconnect from $wifiDirectGroup, failed because: $failureReason")
701 | showSnackbarLengthIndefinite("Error when trying to disconnect from $wifiDirectGroup, failed because: $failureReason")
702 | }
703 |
704 | })
705 | }
706 | }
707 | }
708 |
709 | private fun getFailureReasonForWiFiDirectActionListener(reason: Int): String {
710 | return when (reason) {
711 | WifiP2pManager.ERROR -> {
712 | "Internal Error"
713 | }
714 | WifiP2pManager.P2P_UNSUPPORTED -> {
715 | "WiFi Direct unsupported on this device"
716 | }
717 | // TODO if this happens every method that received this should try again later
718 | WifiP2pManager.BUSY -> {
719 | "Framework is busy and unable to service the request"
720 | }
721 | // Undocumented in `onFailure` method of ActionListener, but can be returned nonetheless when
722 | // discoverServices(Channel, ActionListener) is called without adding service requests.
723 | WifiP2pManager.NO_SERVICE_REQUESTS -> {
724 | "discoverServices(Channel, ActionListener) failed because no service requests were added"
725 | }
726 | else -> {
727 | "Unknown failure reason"
728 | }
729 | }
730 | }
731 |
732 | private fun showSnackbarLengthIndefinite(text: String): Unit {
733 | Snackbar.make(
734 | root_coordinator_layout,
735 | text,
736 | Snackbar.LENGTH_INDEFINITE
737 | ).show()
738 | }
739 |
740 | // Called as callback when another Activity was started with `startActivityForResult`
741 | override fun onActivityResult(requestCode: Int, resultCode: Int, resultIntentWithData: Intent?): Unit {
742 | // super method called so it can pass results through to correct fragment in this activity that started the request with
743 | // `startActivityForResult`, but does not handle nested fragments correctly!
744 | // So those should call `startActivityForResult` from the parent fragment which handles passing it through then
745 | super.onActivityResult(requestCode, resultCode, resultIntentWithData)
746 |
747 | Log.d(LOG_TAG, "onActivityResult called")
748 |
749 | // if it was answer to opening a file
750 | if (requestCode == OPEN_FILE_WITH_FILE_CHOOSER_REQUEST_CODE && resultCode == Activity.RESULT_OK) {
751 | // The document selected by the user won't be returned in the intent.
752 | // Instead, a URI to that document will be contained in the return intent
753 | // provided to this method as a parameter.
754 | val uriOfSelectedFile: Uri = resultIntentWithData?.data?.also { uri ->
755 | Log.v(LOG_TAG, "Uri of file to send, chosen by user: $uri")
756 | dumpContentUriMetaData(uri)
757 | } ?: TODO("handle error, did not receive uri of content in intent data from the system file chooser")
758 |
759 |
760 | Log.v(LOG_TAG, "Currently connected WiFi Direct Device Info: $wiFiDirectGroupInfo")
761 |
762 | // use the group owner address if we are the client and the client address if we are the group owner
763 | // we only know the client address if the client connected to us and we saved the address
764 |
765 | val connectedDeviceIpAddress: InetAddress? = if (wiFiDirectGroupInfo?.isGroupOwner == true) {
766 | connectedClientWiFiDirectIpAddress
767 | } else { // other device is group owner
768 | wiFiDirectGroupInfo?.groupOwnerAddress
769 |
770 | }
771 |
772 | if (connectedDeviceIpAddress == null) {
773 | // make log and ui error more clear depending on if we are the group owner
774 | Log.w(
775 | LOG_TAG, "Connection to wifi direct peer lost while user chose a file to send! or we don't know the ip address " +
776 | "of the wifi direct group client yet!"
777 | )
778 | Snackbar.make(
779 | root_coordinator_layout,
780 | // could also be that the other device has not connected to us yet but the wifi direct connection is fine
781 | getString(R.string.wifi_direct_connection_lost_please_connect_again),
782 | Snackbar.LENGTH_LONG
783 | ).show()
784 | return
785 | }
786 |
787 | // start the Android Service for sending the file and pass it what to send and where
788 | val sendFileServiceIntent: Intent = Intent(this, SendFileOrNotifyOfIpAddressIntentService::class.java).apply {
789 | action = SendFileOrNotifyOfIpAddressIntentService.ACTION_SEND_FILE
790 | data = uriOfSelectedFile
791 | putExtra(SendFileOrNotifyOfIpAddressIntentService.EXTRAS_OTHER_DEVICE_IP_ADDRESS, connectedDeviceIpAddress.hostAddress)
792 | }
793 |
794 | startService(sendFileServiceIntent)
795 | }
796 | }
797 |
798 |
799 | fun dumpContentUriMetaData(uriWithContentScheme: Uri): Unit {
800 |
801 | if (uriWithContentScheme.scheme != "content") {
802 | Log.w(LOG_TAG, "Cannot currently dump metadata of uris that do not have the content uri scheme!")
803 | return
804 | }
805 |
806 | // The query, since it only applies to a single document, will only return
807 | // one row. There's no need to filter, sort, or select fields, since we want
808 | // all fields for one document.
809 | // use projection to only needed columns needed to not waste resources?
810 | val cursor: Cursor? = contentResolver.query(uriWithContentScheme, null, null, null, null, null)
811 |
812 | cursor?.use {
813 | // moveToFirst() returns false if the cursor has 0 rows. Very handy for
814 | // "if there's anything to look at, look at it" conditionals.
815 | if (it.moveToFirst()) {
816 |
817 | // Note it's called "Display Name". This is
818 | // provider-specific, and might not necessarily be the file name.
819 | // TODO handle potential exception and null return value
820 | val displayName =
821 | it.getString(it.getColumnIndex(OpenableColumns.DISPLAY_NAME))
822 | Log.v(LOG_TAG, "Display Name: $displayName")
823 |
824 | // returns -1 if column with this name cannot be found
825 | val columnIndexForFileSize: Int = it.getColumnIndex(OpenableColumns.SIZE).also { columnIndex ->
826 | if (columnIndex == -1) {
827 | TODO("handle error, cannot find SIZE column in content resolver of the uri")
828 | }
829 | }
830 |
831 | // If the size is unknown, the value stored is null. But since an
832 | // int can't be null in Java, the behavior is implementation-specific,
833 | // which is just a fancy term for "unpredictable". So as
834 | // a rule, check if it's null before assigning to an int. This will
835 | // happen often: The storage API allows for remote files, whose
836 | // size might not be locally known.
837 | val fileSize = if (!it.isNull(columnIndexForFileSize)) {
838 | // Technically the column stores an int, but cursor.getString()
839 | // will do the conversion automatically.
840 | // TODO handle potential exception (and null return value?) if for example file size is unknown
841 | it.getString(columnIndexForFileSize)
842 | } else {
843 | "Unknown"
844 | }
845 | Log.v(LOG_TAG, "File size: $fileSize bytes")
846 | // everything went well, no uncaught errors, return without logging an error
847 | return
848 | }
849 | }
850 | Log.w(LOG_TAG, "couldn't get metadata of file with uri $uriWithContentScheme")
851 | }
852 | }
--------------------------------------------------------------------------------
/app/src/main/java/dev/akampf/fileshare/WiFiDirectBackgroundService.kt:
--------------------------------------------------------------------------------
1 | package dev.akampf.fileshare
2 |
3 | import android.app.Notification
4 | import android.app.PendingIntent
5 | import android.app.Service
6 | import android.content.Intent
7 | import android.os.Binder
8 | import android.os.IBinder
9 | import android.util.Log
10 | import androidx.core.app.NotificationCompat
11 | import androidx.core.content.ContextCompat
12 | import androidx.localbroadcastmanager.content.LocalBroadcastManager
13 | import kotlinx.coroutines.*
14 | import kotlinx.coroutines.sync.Mutex
15 | import kotlinx.coroutines.sync.withLock
16 | import java.io.*
17 | import java.net.ServerSocket
18 | import java.net.Socket
19 | import java.net.SocketTimeoutException
20 | import java.util.concurrent.CancellationException
21 | import kotlin.time.Duration
22 | import kotlin.time.ExperimentalTime
23 | import kotlin.time.minutes
24 | import kotlin.time.seconds
25 |
26 |
27 | // TODO wakelock for when screen off needed?
28 |
29 | @ExperimentalTime
30 | class WiFiDirectBackgroundService : Service() {
31 |
32 | companion object {
33 |
34 | // overwritten in listening functions for more clarity
35 | const val LOG_TAG: String = "BackgroundService"
36 |
37 | const val ONGOING_NOTIFICATION_ID: Int = 2
38 |
39 | const val PENDING_INTENT_FOR_NOTIFICATION_REQUEST_CODE: Int = 2
40 |
41 | // Intent action used for broadcasting the result of the service: the ip address of the other device
42 | const val ACTION_REPORT_IP_ADDRESS_OF_CONNECTED_DEVICE =
43 | "${BuildConfig.APPLICATION_ID}.wifi_direct.ACTION_REPORT_IP_ADDRESS_OF_CONNECTED_DEVICE"
44 |
45 | // key for the extra containing the IP address in the broadcasted Intent
46 | const val EXTRA_IP_ADDRESS_OF_CONNECTED_DEVICE = "${BuildConfig.APPLICATION_ID}.wifi_direct.EXTRA_IP_ADDRESS_OF_CONNECTED_DEVICE"
47 |
48 |
49 | const val ACTION_REPORT_NEW_FILE_RECEIVED_FROM_CONNECTED_DEVICE =
50 | "${BuildConfig.APPLICATION_ID}.wifi_direct.ACTION_REPORT_NEW_FILE_RECEIVED_FROM_CONNECTED_DEVICE"
51 |
52 | const val EXTRA_ABSOLUTE_PATH_TO_NEWLY_RECEIVED_FILE_FROM_CONNECTED_DEVICE =
53 | "${BuildConfig.APPLICATION_ID}.wifi_direct.EXTRA_ABSOLUTE_PATH_TO_NEWLY_RECEIVED_FILE_FROM_CONNECTED_DEVICE"
54 |
55 | const val RECEIVED_FILES_DIRECTORY_NAME: String = "received_files"
56 |
57 | var MAXIMUM_DURATION_FOR_SERVICE_LISTENING_IN_BACKGROUND = 5.minutes
58 | }
59 |
60 | private var listenForIpAddressCoroutineJob: Job? = null
61 |
62 | private var listenForFileTransferCoroutineJob: Job? = null
63 |
64 | // Supervisorjob lets a child coroutine fail without cancelling the others, so we could just restart the child
65 | private val backgroundServiceSupervisorJob = SupervisorJob()
66 |
67 | private val backgroundServiceCoroutineScope = CoroutineScope(Dispatchers.Main + backgroundServiceSupervisorJob)
68 |
69 |
70 | private val durationUntilServiceStopsMutex = Mutex()
71 | private var durationUntilServiceStops = MAXIMUM_DURATION_FOR_SERVICE_LISTENING_IN_BACKGROUND
72 |
73 | // Binder given to clients
74 | private val binder = LocalBinder()
75 |
76 | /**
77 | * Class used for the client Binder. Because we know this service always
78 | * runs in the same process as its clients, we don't need to deal with IPC.
79 | */
80 | inner class LocalBinder : Binder() {
81 | // Return this instance of the service so clients can call public methods
82 | fun getService(): WiFiDirectBackgroundService = this@WiFiDirectBackgroundService
83 | }
84 |
85 |
86 | override fun onBind(intent: Intent): IBinder {
87 | Log.d(LOG_TAG, "onBind of ${this::class.simpleName} with intent: $intent")
88 | onBindOrRebind()
89 | return binder
90 | }
91 |
92 | override fun onRebind(intent: Intent?) {
93 | super.onRebind(intent)
94 | Log.d(LOG_TAG, "onRebind")
95 | onBindOrRebind()
96 | }
97 |
98 | private fun onBindOrRebind() {
99 | // also ensure service is started so it doesn't stop on unbind but can handle its own timeout for when to exit
100 | // multiple [startForegroundService] calls only result in multiple [onStartCommand] calls, but are ok
101 | ContextCompat.startForegroundService(applicationContext, Intent(applicationContext, this::class.java))
102 | // must be called shortly after startForegroundService
103 | startForegroundServiceWithNotification()
104 | Log.d(LOG_TAG, "after startForegroundService, setting duration to infinite")
105 | // this should not take long, since while having the lock the check coroutine only reads and writes the
106 | // value and sometimes stops the service
107 | // Probably harder to debug errors if we would launch this in a normal coroutine
108 | runBlocking {
109 | durationUntilServiceStopsMutex.withLock("reset timeout to infinity ir onBindOrRebind") {
110 | durationUntilServiceStops = Duration.INFINITE
111 | Log.d(LOG_TAG, "duration until service stops is: $durationUntilServiceStops")
112 | }
113 | }
114 | }
115 |
116 |
117 | override fun onUnbind(intent: Intent?): Boolean {
118 | Log.d(LOG_TAG, "onUnbind of ${this::class.simpleName} with intent: $intent")
119 | // this should not take long, since while having the lock the check coroutine only reads and writes the
120 | // value and sometimes stops the service
121 | // Probably harder to debug errors if we would launch this in a normal coroutine
122 | runBlocking {
123 | durationUntilServiceStopsMutex.withLock("set timeout to max value in onUnbind") {
124 | durationUntilServiceStops = MAXIMUM_DURATION_FOR_SERVICE_LISTENING_IN_BACKGROUND
125 | }
126 | }
127 |
128 | // returning true means onRebind is called when other clients bind to the service later
129 | // after all have unbound
130 | // Important because otherwise binding to the service again calls no method in the service
131 | // and thus the service doesn't know a client connected, which we use for setting the stop
132 | // timeout to infinity
133 | return true
134 | }
135 |
136 | lateinit var jobCheck: Job
137 |
138 | override fun onCreate() {
139 | Log.d(LOG_TAG, "onCreate started...")
140 |
141 | // only for debugging:
142 | GlobalScope.launch(Dispatchers.Main) {
143 | while (isActive) {
144 | Log.d(LOG_TAG, "$coroutineContext running code")
145 | delay(30_000)
146 | }
147 | }
148 |
149 | checkServiceTimeoutInBackground()
150 |
151 | Log.d(LOG_TAG, "startListening...")
152 | startListening()
153 |
154 | super.onCreate()
155 | }
156 |
157 | private fun checkServiceTimeoutInBackground(): Unit {
158 | // doesn't seem necessary anymore..... (we *are* able to use main dispatcher here)
159 | // not really resource intensive but we want it to complete in the background while blocking main with runblocking
160 | // in onDestroy
161 | jobCheck = backgroundServiceCoroutineScope.launch(Dispatchers.Main) {
162 | val checkInterval = 2.seconds
163 | Log.d(LOG_TAG, "check interval: $checkInterval")
164 | while (true) {
165 | durationUntilServiceStopsMutex.withLock("timeout check coroutine started in onCreate") {
166 | Log.d(LOG_TAG, "duration until service stops: $durationUntilServiceStops")
167 | when {
168 | durationUntilServiceStops <= Duration.ZERO -> {
169 | Log.d(LOG_TAG, "Stopping service (stopSelf)...")
170 | // todo: is this blocking and should be done outside of lock?
171 | stopSelf()
172 | Log.d(LOG_TAG, "after stopSelf, returning out of this coroutine")
173 | return@launch
174 | }
175 | durationUntilServiceStops == Duration.INFINITE -> {
176 | // let everything run, just check again later
177 | //Log.v(LOG_TAG, "Duration until service stops is infinite")
178 | }
179 | else -> durationUntilServiceStops -= checkInterval
180 | }
181 | }
182 | //Log.v(LOG_TAG, "delaying for 2 seconds...")
183 | delay(2.seconds)
184 | }
185 | }
186 | }
187 |
188 | override fun onDestroy(): Unit {
189 | Log.i(LOG_TAG, "onDestroy starting...")
190 | Log.i(
191 | LOG_TAG, "If listen for ip address and file transfer coroutine was running, we cancel those now and wait " +
192 | "(blocking current thread) for their accept() timeouts and then cleanup of resources to complete..."
193 | )
194 |
195 | // todo: how long is it okay for onDestroy to block main thread? should it only cancel the coroutine?
196 | // (the coroutines we are waiting for with cancel
197 | // and join and thus the thread calling runBlocking
198 | // could wait as long as the timeout of accept() in the functions in the coroutines)
199 | // Other method to interrupt the accept() calls?
200 | runBlocking {
201 | backgroundServiceCoroutineScope.cancel()
202 | backgroundServiceSupervisorJob.join()
203 | }
204 | Log.i(LOG_TAG, "Completed waiting for coroutine to finish.")
205 | super.onDestroy()
206 | }
207 |
208 |
209 | private fun startListening() {
210 | Log.d(LOG_TAG, "startListening function start...")
211 | if (listenForIpAddressCoroutineJob == null) {
212 | listenForIpAddressCoroutineJob = backgroundServiceCoroutineScope.launch(Dispatchers.Default) {
213 | while (isActive) {
214 | Log.d(LOG_TAG, "start startListeningForIpAddressOfOtherDevice in coroutine")
215 | startListeningForIpAddressOfOtherDevice()
216 | }
217 | Log.d(LOG_TAG, "listenForIpCoroutine: current job not active anymore")
218 | }
219 | }
220 | if (listenForFileTransferCoroutineJob == null) {
221 | listenForFileTransferCoroutineJob = backgroundServiceCoroutineScope.launch(Dispatchers.Default) {
222 | while (isActive) {
223 | Log.d(LOG_TAG, "start startListeningForFileTransfer in coroutine")
224 | startListeningForFileTransfer()
225 | }
226 | Log.d(LOG_TAG, "listenFileCoroutine: current job not active anymore")
227 | }
228 | }
229 | Log.d(LOG_TAG, "after launching start ip listen coroutine job")
230 |
231 | }
232 |
233 | private suspend fun startListeningForFileTransfer() {
234 | withContext(Dispatchers.IO) {
235 | //startListeningForIpAddressOrFileTransferOfOtherDevice(true)
236 | }
237 | delay(20_000)
238 | Log.d(LOG_TAG, "listenForFile after withcontext")
239 | }
240 |
241 | private suspend fun startListeningForIpAddressOfOtherDevice() {
242 | withContext(Dispatchers.IO) {
243 | startListeningForIpAddressOrFileTransferOfOtherDevice(false)
244 | }
245 | Log.d(LOG_TAG, "listenForIp after withcontext")
246 | }
247 |
248 |
249 | private fun startForegroundServiceWithNotification() {
250 | // promote this service to a foreground service to always display an ongoing notification
251 | // and when the app gets in the background, it is needed to keep it running
252 |
253 | val startMainActivityPendingIntent: PendingIntent =
254 | Intent(this, MainActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK).let { startMainActivityExplicitIntent ->
255 | PendingIntent.getActivity(this, PENDING_INTENT_FOR_NOTIFICATION_REQUEST_CODE, startMainActivityExplicitIntent, 0)
256 | }
257 |
258 | // make sure the notification channel is created for Android 8+ before using it to post a notification
259 | createWiFiDirectConnectionNotificationChannelIfSupported(this)
260 |
261 |
262 | val notification: Notification = NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID_WIFI_DIRECT_CONNECTION)
263 | .setContentTitle("Waiting to finish establishing connection...")
264 | .setContentText(getText(R.string.wifi_direct_connection_establishing_notification_message))
265 |
266 | // replace with app icon
267 | .setSmallIcon(R.drawable.ic_launcher_foreground)
268 |
269 | .setContentIntent(startMainActivityPendingIntent)
270 | .setTicker("Receiving IP Address or file of other device...")
271 | // The priority determines how intrusive the notification should be on Android 7.1 and lower.
272 | // For Android 8.0 and higher, the priority depends on the channel importance of the notification channel
273 | // used above in the constructor.
274 | .setPriority(NotificationCompat.PRIORITY_DEFAULT)
275 | .build()
276 |
277 | startForeground(ONGOING_NOTIFICATION_ID, notification)
278 | }
279 |
280 | // Attention! Only call this with an IO Dispatcher cause it executes blocking IO calls
281 | // in normal execution as well as when closing its resources
282 | private suspend fun startListeningForIpAddressOrFileTransferOfOtherDevice(waitForFileTransferAfterReceivingIpAddress: Boolean): Unit {
283 | val LOG_TAG = if (waitForFileTransferAfterReceivingIpAddress) "WaitForFileFunction" else "ListenForIpFunction"
284 |
285 | Log.d(LOG_TAG, "startListeningForIpAddressOrFileTransferOfOtherDevice function started")
286 |
287 |
288 | val serverPort = if (waitForFileTransferAfterReceivingIpAddress) {
289 | SERVER_PORT_FILE_TRANSFER
290 | } else {
291 | SERVER_PORT_NOTIFY_OF_IP_ADDRESS
292 | }
293 |
294 | var socketToConnectedClient: Socket? = null
295 | var serverSocket: ServerSocket? = null
296 | var dataInputStream: DataInputStream? = null
297 | var fileOutputStreamToDestinationFile: FileOutputStream? = null
298 |
299 |
300 |
301 | try {
302 | Log.i(LOG_TAG, "Starting server socket on port $serverPort")
303 | // Consider using less used but fixed port, ephemeral port, or fixed and likely not blocked port.
304 | serverSocket = ServerSocket(serverPort)
305 |
306 | serverSocket.soTimeout = 5.seconds.inMilliseconds.toInt()
307 | // default timeout is 0, meaning no timeout
308 | Log.i(
309 | LOG_TAG,
310 | "timeout of server socket when receiving ip in milliseconds is: ${serverSocket.soTimeout} (0 means no timeout)"
311 | )
312 |
313 | Log.i(LOG_TAG, "We are now accepting connections to ip ${serverSocket.inetAddress} on port ${serverSocket.localPort}...")
314 | // Wait for client connections. This call blocks until a connection from a client is accepted.
315 |
316 |
317 | // look into if non-blocking accept is possible with socket channels and that makes it easier to
318 | // cancel an accept currently listening?
319 | while (true) {
320 | try {
321 | //Log.d(LOG_TAG, "accept() call")
322 | socketToConnectedClient = serverSocket.accept()
323 | // continue normal execution if accept was successful
324 | break
325 | } catch (e: SocketTimeoutException) {
326 | Log.v(LOG_TAG, "Socket timeout exception: $e")
327 | // Calling the suspending function yield() gives a chance for this coroutine to be
328 | // cancelled every time the current timeout of listening for connections is reached.
329 | // If the coroutine is not cancelled, that means we should keep trying to listen forever.
330 | // The small gaps should not be a problem because the other side tries to connect for at least a second
331 | // or a few seconds, so we do not have to continually listen.
332 | // If the coroutine has been cancelled yield() throws a [CancellationException], so we can do
333 | // cleanup for the socket in the finally clause.
334 | yield()
335 | }
336 | }
337 | if (socketToConnectedClient == null) {
338 | throw IOException("Socket to connected client was null after successful accept() call!")
339 | }
340 | // maybe we should save the state that someone connected somewhere so we do not cancel the coroutine
341 | // and the service if there was just a connection at the end of the timeout and this function is still
342 | // executing the rest
343 | Log.i(
344 | LOG_TAG, "Connection from client with ip ${socketToConnectedClient.inetAddress} from " +
345 | "port ${socketToConnectedClient.port} to ip ${socketToConnectedClient.localAddress} on port ${socketToConnectedClient.localPort} " +
346 | "accepted."
347 | )
348 |
349 | val localIntentIpAddressReceived = Intent(ACTION_REPORT_IP_ADDRESS_OF_CONNECTED_DEVICE).apply {
350 | // use serialization to pass the inetaddress for now to retain all info like eventually already resolved hostname
351 | // is that a risk (or even too slow) and passing raw ip string or byte array and reconstructing inetaddress object is better?
352 | putExtra(EXTRA_IP_ADDRESS_OF_CONNECTED_DEVICE, socketToConnectedClient.inetAddress)
353 | }
354 | Log.d(LOG_TAG, "sending local broadcast with ip address of other device...")
355 | LocalBroadcastManager.getInstance(this).sendBroadcast(localIntentIpAddressReceived)
356 |
357 |
358 | val durationToSleep = 2.seconds
359 | Log.d(LOG_TAG, "sleep $durationToSleep ...")
360 | Thread.sleep(durationToSleep.toLongMilliseconds())
361 | Log.d(LOG_TAG, "Woke up again!")
362 |
363 | if (!waitForFileTransferAfterReceivingIpAddress) {
364 | return
365 | }
366 |
367 |
368 | // Save the input stream from the client as a file in internal app directory as proof of concept
369 |
370 | val inputStreamFromConnectedDevice = socketToConnectedClient.getInputStream()
371 |
372 | val bufferedInputStream: BufferedInputStream = BufferedInputStream(inputStreamFromConnectedDevice)
373 | dataInputStream = DataInputStream(bufferedInputStream)
374 |
375 | // read file name with readUTF function for now, then create file in filesystem to write received data to
376 |
377 | // see: https://cwe.mitre.org/data/definitions/1219.html
378 | // TODO this is user supplied data transferred over to this device, we can't trust its content, how to escape properly for using as
379 | // file name for saving to disk etc? what if contains illegal characters for filename, like slash, which would
380 | // maybe even allow path traversal attacks through relative paths pointing to parent directories (escape slashes etc with some method)
381 | // how to handle non-printable characters or homographs? (disallow them?)
382 | // when file gets saved later, check for similar (homograph, only different case) files in directory and rename it with feedback
383 | // for user? just append some random string?
384 | // what if file name is the empty string? (generate name?)
385 | // see: https://cwe.mitre.org/data/definitions/22.html
386 | // outright reject whole request if filename contains known malicious input like slashes or maybe exceeds certain size etc
387 | // and report this to user if clear so they can distrust the sender
388 | // check allow list of file types or at least check deny list and report to user / warn (e.g. its an apk)
389 | // see: https://cwe.mitre.org/data/definitions/434.html
390 | // just always generate a filename to not deal with untrusted user input more than necessary
391 | // or at least escape every character except in allow list like alphanumeric characters
392 | // and after the transform check against an allowlist as final step (transforms can introduce vulnerabilities, e.g. if escaping sth)
393 | val fileName: String = dataInputStream.readUTF()
394 |
395 | Log.i(LOG_TAG, "File name received: $fileName")
396 |
397 |
398 | // open/create subdirectory only readable by our own app in app private storage, note that this is not created in the `nobackup`
399 | // directory so if we use backup, we should not backup those big files in this subdirectory
400 | // TODO POTENTIAL BREAKAGE filesDir gives us a subdirectory in the data directory named "files", we use this name in the content
401 | // provider (file provider) sharable file paths definition, so if the name is different on another android version, the app breaks
402 | val receivedFilesDirectory: File = File(filesDir, RECEIVED_FILES_DIRECTORY_NAME)
403 |
404 | val destinationFile: File = File(
405 | // replace by letting user choose where to save or starting download while letting user choose or letting user choose
406 | // afterwards if wants to view, edit etc file, but choosing save location might make sense earlier. view, save, edit etc shortcuts
407 | // maybe also in transfer complete notification as action
408 | receivedFilesDirectory.absolutePath, fileName
409 | )
410 |
411 | fun File.doesNotExist(): Boolean = !exists()
412 |
413 |
414 | // generally handle parent directory creation better and handle errors when we can't create every parent directory, throw error
415 | // and exit app when parent directory that should always exist is not there, like in our case with the filesDir directory of the
416 | // app private storage?
417 | // if using nested directories, take care to create every parent directory
418 | // if destination file has a parent directory (seen in its path), but that does not exist, create it:
419 | if (destinationFile.parentFile?.doesNotExist() == true) {
420 | // why assert non-null needed, shouldn't this condition return false when parent file is null?
421 | // create the `received_files` directory if not already existent
422 | // TODO better do this above before involvement of untrusted user data in form ot the sent file name
423 | destinationFile.parentFile!!.mkdir()
424 | }
425 |
426 | // TODO handle when file already exists, but multiple transfers with same file name should be possible!
427 | // consider locking file access?
428 | // don't transfer file when it is the same exact file, check hash
429 | // handle other io and security errors
430 | val destinationFileSuccessfullyCreated: Boolean = destinationFile.createNewFile()
431 |
432 | if (!destinationFileSuccessfullyCreated) {
433 | Log.e(
434 | LOG_TAG, "TODO this MUST be handled! destination file (path: ${destinationFile.path}) to save received content to " +
435 | "not created because file with same name already exists! absolute path: ${destinationFile.absolutePath}"
436 | )
437 | }
438 |
439 | try {
440 | fileOutputStreamToDestinationFile = FileOutputStream(destinationFile)
441 | } catch (e: Exception) {
442 | when (e) {
443 | is FileNotFoundException, is SecurityException -> {
444 | // !!!
445 | // THIS CODE SHOULD NEVER BE REACHED!
446 | //
447 | // This should never happen as the path to the file is internally constructed in the app only influenced by the received name
448 | // for the content. The constructed path should always be existent and writable as it is in an app private directory. So neither a
449 | // FileNotFoundException nor a SecurityException should be possible to be thrown.
450 | // !!!
451 | Log.wtf(
452 | LOG_TAG, "Error on opening file in app private directory, which should always be possible! " +
453 | "App is in an undefined state, trying to exit app...", e
454 | )
455 | // Something is severely wrong with the state of the app (maybe a remote device tried to attack us), so it should throw
456 | // an error which is NOT caught by some other code and exit the app, meaning we should not try to recover from an undefined state
457 | // or from an attack but we should fail safely by quitting
458 | TODO("how to guarantee exiting all of the app without something intercepting it? ensure exceptions from this method are not handled")
459 | }
460 | else -> throw e
461 | }
462 |
463 | }
464 |
465 |
466 | // TODO handle interrupted and partial transfers correctly, don't display / save
467 | // TODO this is user supplied data transferred over to this device, we can't trust its content, should we do some checks for its
468 | // content in general (anti-malware etc) or for methods and programs that use it like processing in this app, FileProvider, etc?
469 | val contentSuccessfullyReceived: Boolean =
470 | copyBetweenByteStreamsAndFlush(inputStreamFromConnectedDevice, fileOutputStreamToDestinationFile)
471 |
472 |
473 | if (contentSuccessfullyReceived) {
474 | Log.i(
475 | LOG_TAG, "File name and content successfully received and written, error on resources closing " +
476 | "might still occurred"
477 | )
478 | }
479 |
480 | val localIntentNewFileReceived: Intent = Intent(ACTION_REPORT_NEW_FILE_RECEIVED_FROM_CONNECTED_DEVICE).apply {
481 | putExtra(EXTRA_ABSOLUTE_PATH_TO_NEWLY_RECEIVED_FILE_FROM_CONNECTED_DEVICE, destinationFile.absolutePath)
482 | }
483 | Log.d(LOG_TAG, "sending local broadcast with path to newly received file of other device...")
484 | LocalBroadcastManager.getInstance(this).sendBroadcast(localIntentNewFileReceived)
485 |
486 |
487 | } catch (cancellationException: CancellationException) {
488 | Log.d(
489 | LOG_TAG, "Job/Coroutine that was listening for ip address or file was cancelled:",
490 | cancellationException
491 | )
492 | // the coroutine was cancelled, so we should stop listening for connections
493 | // still do any cleanup if necessary through the finally clause
494 | } catch (exception: IOException) {
495 | Log.e(LOG_TAG, "IO Error while (waiting for) receiving ip address or file of other device!", exception)
496 | TODO("Handle errors appropriately and fine grained")
497 | } finally {
498 | Log.d(LOG_TAG, "Doing cleanup of sockets in finally...")
499 | // we now know the ip address of the other device (for us to connect to it in the future) so we can close the connection
500 | try {
501 |
502 |
503 | // close the outermost stream to call its flush method before it in turn closes any underlying streams and used resources
504 |
505 | // TODO why is socket to connected client not closed after closing input stream of it, is that a problem?
506 | // is it really not? check if it is, maybe also try to close the socket
507 | dataInputStream?.close()
508 |
509 | Log.d(
510 | LOG_TAG,
511 | "socket to connected client closed state after closing dataInputStream: ${socketToConnectedClient?.isClosed}"
512 | )
513 |
514 | // is it bad when this has an error? check integrity of file?
515 | fileOutputStreamToDestinationFile?.close()
516 |
517 |
518 | if (socketToConnectedClient?.isClosed == false) {
519 | socketToConnectedClient.close()
520 | }
521 | if (serverSocket?.isClosed == false) {
522 | serverSocket.close()
523 | }
524 | } catch (exception: IOException) {
525 | // if closing of socket fails, is that bad and should be handled somehow?
526 | // preserve original exception if there was one?
527 | Log.e(LOG_TAG, "IO error when closing sockets or input/output streams!", exception)
528 | TODO("Handle error on closing server socket and socket to client appropriately")
529 | }
530 | }
531 | Log.d(LOG_TAG, "end of function startListeningForIpAddressOfOtherDevice")
532 |
533 | }
534 | }
--------------------------------------------------------------------------------
/app/src/main/java/dev/akampf/fileshare/WiFiDirectBroadcastReceiver.kt:
--------------------------------------------------------------------------------
1 | package dev.akampf.fileshare
2 |
3 | // see comment in imports in MainActivity for explanation of the `kotlinx.android.synthetic...` import
4 | import android.content.BroadcastReceiver
5 | import android.content.Context
6 | import android.content.Intent
7 | import android.net.NetworkInfo
8 | import android.net.wifi.p2p.WifiP2pGroup
9 | import android.net.wifi.p2p.WifiP2pInfo
10 | import android.net.wifi.p2p.WifiP2pManager
11 | import android.util.Log
12 | import androidx.core.content.ContextCompat
13 | import com.google.android.material.snackbar.Snackbar
14 | import kotlinx.android.synthetic.main.activity_main.*
15 | import java.net.InetAddress
16 | import kotlin.time.ExperimentalTime
17 |
18 |
19 | private const val LOG_TAG: String = "WiFiDirectBrdcastRcvr"
20 |
21 |
22 | // TODO should this class really receive the wifi direct manager and channel or just notify some other code of the change, this would
23 | // make the complicated instantiation of this class obsolete and also decouple the notifying of changes of the wifi direct manager logic,
24 | // where the connection to the manager can be lost etc (so this class would not be affected by that wifi direct manager lost connection
25 | // callback)
26 | /**
27 | * A BroadcastReceiver that handles / notifies of important Wi-Fi Direct events.
28 | */
29 | @ExperimentalTime
30 | class WiFiDirectBroadcastReceiver(
31 | private val wiFiDirectManager: WifiP2pManager,
32 | private val wiFiDirectChannel: WifiP2pManager.Channel,
33 | private val mainActivity: MainActivity
34 | ) : BroadcastReceiver() {
35 |
36 | override fun onReceive(context: Context, intent: Intent): Unit {
37 |
38 | when (intent.action) {
39 | WifiP2pManager.WIFI_P2P_STATE_CHANGED_ACTION -> {
40 | // Check to see if Wi-Fi Direct is enabled and notify appropriate activity
41 |
42 | // TODO in emulator and some real devices on activity resume and wifi enabled (only when connected?) the wifi direct state
43 | // constantly toggles between on and off until wifi is disabled, then enabling it (and connecting to a network) does not
44 | // lead to the toggling behavior again
45 | when (val wiFiDirectState: Int = intent.getIntExtra(WifiP2pManager.EXTRA_WIFI_STATE, -1)) {
46 | WifiP2pManager.WIFI_P2P_STATE_ENABLED -> {
47 | Log.i(LOG_TAG, "WiFi Direct is ENABLED")
48 | mainActivity.wiFiDirectEnabled = true
49 | mainActivity.wiFiDirectStatusInitialized = true
50 | }
51 | WifiP2pManager.WIFI_P2P_STATE_DISABLED -> {
52 | Log.i(LOG_TAG, "WiFi Direct is DISABLED")
53 | mainActivity.wiFiDirectEnabled = false
54 | mainActivity.wiFiDirectStatusInitialized = true
55 | }
56 | else -> {
57 | // === THIS STATE SHOULD NEVER BE REACHED ===
58 |
59 | // the EXTRA_WIFI_STATE extra of the WiFi Direct State Changed intent was not found (state == -1) or the state is neither
60 | // enabled (state == 2) nor disabled (state == 1), which should be the only 2 options when the extra is present
61 | // The extra also should be present all the time, when receiving the Wifi Direct state changed action
62 | Log.wtf(
63 | LOG_TAG, "=== THIS STATE SHOULD NEVER BE REACHED!!! === \n" +
64 | "WIFI_P2P_STATE_CHANGED_ACTION intent received but not an EXTRA_WIFI_STATE " +
65 | "extra with enabled or disabled state!\nExtra value (-1 if EXTRA_WIFI_STATE extra not found) = $wiFiDirectState"
66 | )
67 | }
68 | }
69 |
70 | }
71 | WifiP2pManager.WIFI_P2P_PEERS_CHANGED_ACTION -> {
72 | Log.d(LOG_TAG, "WiFi Direct Peers Changed Intent received")
73 | // The discovery finished, now we can request a list of current peers, note that it might be empty!
74 | // When this asynchronous method has the results, we notify we main activity and pass the peer list to it.
75 | wiFiDirectManager.requestPeers(wiFiDirectChannel) { peerList -> mainActivity.notifyWiFiDirectPeerListDiscoveryFinished(peerList) }
76 |
77 | }
78 | WifiP2pManager.WIFI_P2P_CONNECTION_CHANGED_ACTION -> {
79 | val wiFiDirectGroupInfo: WifiP2pInfo? = intent.getParcelableExtra(WifiP2pManager.EXTRA_WIFI_P2P_INFO)
80 | mainActivity.wiFiDirectGroupInfo = wiFiDirectGroupInfo
81 |
82 | // use of this is deprecated: https://developer.android.com/reference/android/net/NetworkInfo.html
83 | // but it is described in the documentation that it is an info provided in this intent here:
84 | // https://developer.android.com/reference/kotlin/android/net/wifi/p2p/WifiP2pManager.html#wifi_p2p_connection_changed_action
85 | val networkInfo: NetworkInfo? = intent.getParcelableExtra(WifiP2pManager.EXTRA_NETWORK_INFO)
86 | val wiFiDirectGroup: WifiP2pGroup? = intent.getParcelableExtra(WifiP2pManager.EXTRA_WIFI_P2P_GROUP)
87 | Log.i(
88 | LOG_TAG, "WiFi Direct Connection Status changed:\n" +
89 | "WiFiDirectInfo: $wiFiDirectGroupInfo\n" +
90 | "NetworkInfo: $networkInfo\n" +
91 | "WiFi Direct Group: $wiFiDirectGroup"
92 | )
93 | // Respond to new connection or disconnections
94 |
95 | wiFiDirectManager?.let { wiFiDirectManager ->
96 |
97 | if (networkInfo?.isConnected == true) {
98 |
99 | Log.d(
100 | LOG_TAG, "NetworkInfo says we are connected to a WiFi Direct group and it is possible to establish " +
101 | "connections and pass data"
102 | )
103 |
104 | // We are connected with the other device, request connection info to find group owner IP
105 |
106 | val connectionInfoListener = WifiP2pManager.ConnectionInfoListener { wiFiDirectInfo ->
107 |
108 | val thisOrOtherDeviceText = if (wiFiDirectInfo.isGroupOwner) {
109 | context.getText(R.string.wifi_direct_connection_established_this_device)
110 | } else {
111 | context.getText(R.string.wifi_direct_connection_established_other_device)
112 | }
113 |
114 | Snackbar.make(
115 | mainActivity.root_coordinator_layout,
116 | context.getString(
117 | R.string.wifi_direct_connection_established,
118 | wiFiDirectGroup?.networkName,
119 | wiFiDirectGroup?.owner?.deviceName,
120 | thisOrOtherDeviceText
121 | ),
122 | Snackbar.LENGTH_INDEFINITE
123 | ).show()
124 |
125 |
126 | val groupOwnerIpAddress: InetAddress? = wiFiDirectInfo.groupOwnerAddress.also {
127 | Log.d(LOG_TAG, "group owner address: ${it.hostAddress}")
128 | }
129 |
130 | Log.d(LOG_TAG, "WiFi Direct client list: ${wiFiDirectGroup?.clientList}")
131 |
132 |
133 | // After the group negotiation, we can determine the group owner, who has to start a server cause they do not know the ip
134 | // addresses of the connected clients in the group, only the others know the ip address of the group owner
135 | if (wiFiDirectInfo.groupFormed) {
136 |
137 |
138 | // todo: replace with calling function in service to start listening for files as well as
139 | // currently not working correctly, receiving less bytes that it writes
140 | // start server on both group owner and client, because that is the way the accept incoming connections with data, but
141 | // the client cannot be contacted until the group owner knows its ip address, which we will tell them by connecting on
142 | // another por
143 | ReceiveFileOrGetIpAddressFromOtherDeviceAsyncTask(
144 | mainActivity,
145 | mainActivity.wiFi_direct_status_text_view,
146 | onlyGetNotifiedOfIpAddress = false
147 | ).execute()
148 |
149 |
150 |
151 | if (wiFiDirectInfo.isGroupOwner) {
152 | Log.d(LOG_TAG, "current device is group owner")
153 | // The other device acts as the peer (client).
154 | // Do tasks that are specific to the group owner.
155 |
156 |
157 |
158 | } else {
159 | Log.d(LOG_TAG, "other device is group owner")
160 | // We connect to the group owner just so that they know our ip address (from the connection itself) and can connect
161 | // back to us when they have something to send
162 | val startServiceIntent: Intent = Intent(context, SendFileOrNotifyOfIpAddressIntentService::class.java).apply {
163 | action = SendFileOrNotifyOfIpAddressIntentService.ACTION_NOTIFY_OF_IP_ADDRESS
164 | // if the other device is the group owner the group owner ip address is known and not null
165 | putExtra(SendFileOrNotifyOfIpAddressIntentService.EXTRAS_OTHER_DEVICE_IP_ADDRESS, groupOwnerIpAddress?.hostAddress)
166 | }
167 |
168 | // Unlike the ordinary `startService(Intent)`, this method can be used at
169 | // any time, regardless of whether the app hosting the service is in a foreground state.
170 | // (Only relevant for Android 8+ (API 26+)
171 | ContextCompat.startForegroundService(context, startServiceIntent)
172 | }
173 | }
174 | }
175 |
176 |
177 | wiFiDirectManager.requestConnectionInfo(wiFiDirectChannel, connectionInfoListener)
178 | }
179 | }
180 |
181 |
182 | }
183 | WifiP2pManager.WIFI_P2P_THIS_DEVICE_CHANGED_ACTION -> {
184 | // Respond to this device's wifi state changing
185 | // https://developer.android.com/reference/android/net/wifi/p2p/WifiP2pManager#WIFI_P2P_THIS_DEVICE_CHANGED_ACTION
186 | }
187 | // different action or action is null, this should not happen as we did not register for other actions in the intent filter
188 | else -> {
189 | }
190 | }
191 | }
192 | }
193 |
--------------------------------------------------------------------------------
/app/src/main/java/dev/akampf/fileshare/WiFiDirectDeviceFragment.kt:
--------------------------------------------------------------------------------
1 | package dev.akampf.fileshare
2 |
3 | import android.content.Context
4 | import android.net.wifi.p2p.WifiP2pDevice
5 | import android.os.Bundle
6 | import androidx.fragment.app.Fragment
7 | import androidx.recyclerview.widget.GridLayoutManager
8 | import androidx.recyclerview.widget.LinearLayoutManager
9 | import androidx.recyclerview.widget.RecyclerView
10 | import android.view.LayoutInflater
11 | import android.view.View
12 | import android.view.ViewGroup
13 | import kotlin.time.ExperimentalTime
14 |
15 | /**
16 | * A fragment representing a list of WiFi Direct Devices.
17 | * Activities containing this fragment MUST implement the
18 | * [WiFiDirectDeviceFragment.OnListFragmentInteractionListener] interface.
19 | */
20 | @ExperimentalTime
21 | class WiFiDirectDeviceFragment : Fragment() {
22 |
23 | private var columnCount = 1
24 |
25 | private var listener: OnListFragmentInteractionListener? = null
26 |
27 | lateinit var recyclerViewAdapter: WiFiDirectPeerDevicesRecyclerViewAdapter
28 |
29 | override fun onCreate(savedInstanceState: Bundle?) {
30 | super.onCreate(savedInstanceState)
31 |
32 | arguments?.let {
33 | columnCount = it.getInt(ARG_COLUMN_COUNT)
34 | }
35 | }
36 |
37 | override fun onCreateView(
38 | inflater: LayoutInflater, container: ViewGroup?,
39 | savedInstanceState: Bundle?
40 | ): View? {
41 | val view = inflater.inflate(R.layout.fragment_device_list, container, false)
42 |
43 |
44 | // listener was set in onAttach and is the view containing this fragment, in our case the main activity
45 | val mainActivity = listener as MainActivity
46 | // we use that to set a reference to this fragment for the main activity (notified by the broadcast receiver) to later
47 | // interact with the recyclerViewAdapter to notify it of changes to the device list
48 | mainActivity.wiFiDirectDeviceFragment = this
49 |
50 | recyclerViewAdapter = WiFiDirectPeerDevicesRecyclerViewAdapter(mainActivity.wiFiDirectPeers, listener)
51 |
52 | // Set the adapter
53 | if (view is RecyclerView) {
54 | with(view) {
55 | layoutManager = when {
56 | columnCount <= 1 -> LinearLayoutManager(context)
57 | else -> GridLayoutManager(context, columnCount)
58 | }
59 | adapter = recyclerViewAdapter
60 | }
61 | }
62 | return view
63 | }
64 |
65 | override fun onAttach(context: Context) {
66 | super.onAttach(context)
67 | // TODO remove runtime check?
68 | if (context is OnListFragmentInteractionListener) {
69 | listener = context
70 | } else {
71 | throw RuntimeException("$context must implement OnListFragmentInteractionListener")
72 | }
73 | }
74 |
75 | override fun onDetach() {
76 | super.onDetach()
77 | listener = null
78 | }
79 |
80 | /**
81 | * This interface must be implemented by activities that contain this
82 | * fragment to allow an interaction in this fragment to be communicated
83 | * to the activity and potentially other fragments contained in that
84 | * activity.
85 | *
86 | *
87 | * See the Android Training lesson
88 | * [Communicating with Other Fragments](http://developer.android.com/training/basics/fragments/communicating.html)
89 | * for more information.
90 | */
91 | interface OnListFragmentInteractionListener {
92 | fun onListFragmentInteraction(wiFiDirectDevice: WifiP2pDevice)
93 | }
94 |
95 | companion object {
96 |
97 | // TODO: Customize parameter argument names
98 | const val ARG_COLUMN_COUNT = "column-count"
99 |
100 | // TODO: Customize parameter initialization
101 | @JvmStatic
102 | fun newInstance(columnCount: Int) =
103 | WiFiDirectDeviceFragment().apply {
104 | arguments = Bundle().apply {
105 | putInt(ARG_COLUMN_COUNT, columnCount)
106 | }
107 | }
108 | }
109 | }
110 |
--------------------------------------------------------------------------------
/app/src/main/java/dev/akampf/fileshare/WiFiDirectPeerDevicesRecyclerViewAdapter.kt:
--------------------------------------------------------------------------------
1 | package dev.akampf.fileshare
2 |
3 | import android.net.wifi.p2p.WifiP2pDevice
4 | import androidx.recyclerview.widget.RecyclerView
5 | import android.view.LayoutInflater
6 | import android.view.View
7 | import android.view.ViewGroup
8 | import android.widget.TextView
9 |
10 |
11 | import dev.akampf.fileshare.WiFiDirectDeviceFragment.OnListFragmentInteractionListener
12 |
13 | import kotlinx.android.synthetic.main.fragment_device.view.*
14 | import kotlin.time.ExperimentalTime
15 |
16 | /**
17 | * [RecyclerView.Adapter] that can display a [WifiP2pDevice] and makes a call to the
18 | * specified [OnListFragmentInteractionListener] when the representing view is clicked.
19 | */
20 | @ExperimentalTime
21 | class WiFiDirectPeerDevicesRecyclerViewAdapter(
22 | private val mValues: List,
23 | private val mListener: OnListFragmentInteractionListener?
24 | ) : RecyclerView.Adapter() {
25 |
26 | private val mOnClickListener: View.OnClickListener
27 |
28 | init {
29 | // tells the layout manager that it can identify items that only have moved safely by their itemId, leads to view reuse and smooth
30 | // animations even when only notifying of data change without being specific about what moved where (still not efficient to not do that)
31 | setHasStableIds(true)
32 | mOnClickListener = View.OnClickListener { deviceView ->
33 | val wiFiDirectDevice = deviceView.tag as WifiP2pDevice
34 | // Notify the active callbacks interface (the activity, if the fragment is attached to
35 | // one) that an item has been selected.
36 | mListener?.onListFragmentInteraction(wiFiDirectDevice)
37 | }
38 | }
39 |
40 |
41 | // Create new views (invoked by the layout manager)
42 | override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DeviceViewHolder {
43 | val singleDeviceView = LayoutInflater.from(parent.context)
44 | .inflate(R.layout.fragment_device, parent, false)
45 | return DeviceViewHolder(singleDeviceView)
46 | }
47 |
48 |
49 | // Replace the contents of a view (representing a single device) through the ViewHolder (invoked by the layout manager)
50 | override fun onBindViewHolder(deviceViewHolder: DeviceViewHolder, position: Int) {
51 | // - get element from your data set at this position
52 | // - replace the contents of the view with that element
53 | val wiFiDirectDevice = mValues[position]
54 | deviceViewHolder.mIdView.text = wiFiDirectDevice.deviceAddress
55 | deviceViewHolder.mContentView.text = wiFiDirectDevice.deviceName
56 |
57 | with(deviceViewHolder.mView) {
58 | // Set tag for the view that can be clicked to an identifier for the content or the content itself so we can later retrieve
59 | // e.g. the data associated (if only identifier is used) and notify the containing activity of the click with this info, since
60 | // we only know which view was clicked
61 | tag = wiFiDirectDevice
62 | setOnClickListener(mOnClickListener)
63 | }
64 | }
65 |
66 |
67 | // Return the size of the data set (invoked by the layout manager)
68 | override fun getItemCount(): Int = mValues.size
69 |
70 | // Used to determine what items are the same, even when updating the whole list (instead of updating only affected items), decreases
71 | // resource usage and adds smooth transitions to added, removed and moved items in the list.
72 | // Just use mac address hex value converted to long cause it is 48 bit and thus smaller than a 64bit Long so can be used collision free.
73 | override fun getItemId(position: Int): Long {
74 | val wiFiDirectMacAddress: String = mValues[position].deviceAddress
75 | // TODO use assert here for expected form of mac address, what are the places to use assert?
76 | val macAddressHexStringWithoutColons = wiFiDirectMacAddress.replace(":", "")
77 | // radix 16 means we read the String as a hexadecimal number
78 | return macAddressHexStringWithoutColons.toLong(16)
79 | }
80 |
81 | /**
82 | * Provide a reference to the views for each data item
83 | * Complex data items may need more than one view per item, and
84 | * you provide access to all the views for a data item in a view holder.
85 | */
86 | inner class DeviceViewHolder(val mView: View) : RecyclerView.ViewHolder(mView) {
87 | val mIdView: TextView = mView.item_number
88 | val mContentView: TextView = mView.content
89 |
90 | override fun toString(): String {
91 | return super.toString() + " '" + mContentView.text + "'"
92 | }
93 | }
94 | }
95 |
--------------------------------------------------------------------------------
/app/src/main/java/dev/akampf/fileshare/WiFiDirectTransfer.kt:
--------------------------------------------------------------------------------
1 | package dev.akampf.fileshare
2 |
3 | import android.app.*
4 | import android.content.ActivityNotFoundException
5 | import android.content.Context
6 | import android.content.Intent
7 | import android.database.Cursor
8 | import android.net.Uri
9 | import android.os.AsyncTask
10 | import android.os.Build
11 | import android.provider.OpenableColumns
12 | import android.util.Log
13 | import android.widget.TextView
14 | import androidx.core.app.NotificationCompat
15 | import androidx.core.app.NotificationManagerCompat
16 | import androidx.core.content.FileProvider
17 | import com.google.android.material.snackbar.Snackbar
18 | import kotlinx.android.synthetic.main.activity_main.*
19 | import java.io.*
20 | import java.net.*
21 | import kotlin.time.ExperimentalTime
22 | import kotlin.time.seconds
23 |
24 |
25 | private const val LOG_TAG: String = "WiFiDirectTransfer"
26 |
27 | // replace with port likely to be free and not in the popular 8xxx range or even knowingly used
28 | // multiple times like 8888:
29 | // https://en.wikipedia.org/wiki/List_of_TCP_and_UDP_port_numbers
30 | const val SERVER_PORT_FILE_TRANSFER: Int = 8888
31 | const val SERVER_PORT_NOTIFY_OF_IP_ADDRESS: Int = 8889
32 |
33 |
34 | // TODO convert to foreground (intent)service that shuts down connection after 5 minutes if nothing connects
35 | // is started when activity starts if not already running
36 | // does not shut down when transfer in progress
37 |
38 | // receives a file sent via a network socket
39 | @ExperimentalTime
40 | class ReceiveFileOrGetIpAddressFromOtherDeviceAsyncTask(
41 | private val mainActivity: MainActivity,
42 | private var statusText: TextView,
43 | private val onlyGetNotifiedOfIpAddress: Boolean
44 | ) : AsyncTask>() {
45 |
46 | companion object {
47 |
48 | const val RECEIVED_FILES_DIRECTORY_NAME: String = "received_files"
49 |
50 | const val LOG_TAG: String = "ReceiveOrGetIpAsyncTask"
51 | }
52 |
53 | override fun doInBackground(vararg params: Void): Pair {
54 |
55 | // we start a server on a different port for accepting a connection from the wifi direct group client even if it does not send a file
56 | // to know its ip address to connect to it when we want to send something
57 | // But even when we get a normal connection to send something, we update the ip address of the other device afterwards
58 | val serverPort: Int = if (onlyGetNotifiedOfIpAddress) {
59 | SERVER_PORT_NOTIFY_OF_IP_ADDRESS
60 | } else {
61 | SERVER_PORT_FILE_TRANSFER
62 | }
63 | Log.i(LOG_TAG, "AsyncTask started with onlyGetNotifiedOfIpAddress set to: $onlyGetNotifiedOfIpAddress")
64 |
65 |
66 | // TODO handle io exceptions, wrap nearly all of code in this method in try except and handle important exceptions for status reporting
67 | // separately like rethrowing own exception which is handled in own catch clause or saving failure status in variable?
68 |
69 |
70 | Log.i(LOG_TAG, "Starting server socket on port $serverPort")
71 | // Consider using less used but fixed port, ephemeral port, or fixed and likely not blocked port.
72 | val serverSocket = ServerSocket(serverPort)
73 |
74 | Log.i(LOG_TAG, "We are now accepting connections to ip ${serverSocket.inetAddress} on port ${serverSocket.localPort}...")
75 | // Wait for client connections. This call blocks until a connection from a client is accepted.
76 | val socketToConnectedClient = serverSocket.accept()
77 | Log.i(
78 | LOG_TAG, "Connection from client with ip ${socketToConnectedClient.inetAddress} from " +
79 | "port ${socketToConnectedClient.port} to ip ${socketToConnectedClient.localAddress} on port ${socketToConnectedClient.localPort} " +
80 | "accepted."
81 | )
82 |
83 |
84 |
85 |
86 |
87 |
88 | if (onlyGetNotifiedOfIpAddress) {
89 | // we already know the ip address of the other device for us to connect to in the future so we can close the connection
90 | // the ip address stays retrievable after closing the socket
91 | try {
92 | if (!socketToConnectedClient.isClosed) {
93 | socketToConnectedClient.close()
94 | }
95 | if (!serverSocket.isClosed) {
96 | serverSocket.close()
97 | }
98 | } catch (exception: IOException) {
99 | // if closing of socket fails, is that bad and should be handled somehow?
100 | Log.e(LOG_TAG, "IO error while closing sockets!", exception)
101 | TODO("Handle error on closing server socket appropriately")
102 | }
103 |
104 | return Pair(null, socketToConnectedClient.inetAddress)
105 | }
106 |
107 |
108 | // Save the input stream from the client as a file in internal app directory as proof of concept
109 |
110 |
111 | val inputStreamFromConnectedDevice = socketToConnectedClient.getInputStream()
112 |
113 | val bufferedInputStream: BufferedInputStream = BufferedInputStream(inputStreamFromConnectedDevice)
114 | val dataInputStream = DataInputStream(bufferedInputStream)
115 |
116 | // read file name with readUTF function for now, then create file in filesystem to write received data to
117 |
118 | // see: https://cwe.mitre.org/data/definitions/1219.html
119 | // TODO this is user supplied data transferred over to this device, we can't trust its content, how to escape properly for using as
120 | // file name for saving to disk etc? what if contains illegal characters for filename, like slash, which would
121 | // maybe even allow path traversal attacks through relative paths pointing to parent directories (escape slashes etc with some method)
122 | // how to handle non-printable characters or homographs? (disallow them?)
123 | // when file gets saved later, check for similar (homograph, only different case) files in directory and rename it with feedback
124 | // for user? just append some random string?
125 | // what if file name is the empty string? (generate name?)
126 | // see: https://cwe.mitre.org/data/definitions/22.html
127 | // outright reject whole request if filename contains known malicious input like slashes or maybe exceeds certain size etc
128 | // and report this to user if clear so they can distrust the sender
129 | // check allow list of file types or at least check deny list and report to user / warn (e.g. its an apk)
130 | // see: https://cwe.mitre.org/data/definitions/434.html
131 | // just always generate a filename to not deal with untrusted user input more than necessary
132 | // or at least escape every character except in allow list like alphanumeric characters
133 | // and after the transform check against an allowlist as final step (transforms can introduce vulnerabilities, e.g. if escaping sth)
134 | val fileName: String = dataInputStream.readUTF()
135 |
136 |
137 | // open/create subdirectory only readable by our own app in app private storage, note that this is not created in the `nobackup`
138 | // directory so if we use backup, we should not backup those big files in this subdirectory
139 | // TODO POTENTIAL BREAKAGE filesDir gives us a subdirectory in the data directory named "files", we use this name in the content
140 | // provider (file provider) sharable file paths definition, so if the name is different on another android version, the app breaks
141 | val receivedFilesDirectory: File = File(mainActivity.filesDir, RECEIVED_FILES_DIRECTORY_NAME)
142 |
143 | val destinationFile: File = File(
144 | // replace by letting user choose where to save or starting download while letting user choose or letting user choose
145 | // afterwards if wants to view, edit etc file, but choosing save location might make sense earlier. view, save, edit etc shortcuts
146 | // maybe also in transfer complete notification as action
147 | receivedFilesDirectory.absolutePath, fileName
148 | )
149 |
150 | // generally handle parent directory creation better and handle errors when we can't create every parent directory, throw error
151 | // and exit app when parent directory that should always exist is not there, like in our case with the filesDir directory of the
152 | // app private storage?
153 | // if using nested directories, take care to create every parent directory
154 | // if destination file has a parent directory (seen in its path), but that does not exist, create it:
155 | if (destinationFile.parentFile?.doesNotExist() == true) {
156 | // why assert non-null needed, shouldn't this condition return false when parent file is null?
157 | // create the `received_files` directory if not already existent
158 | // TODO better do this above before involvement of untrusted user data in form ot the sent file name
159 | destinationFile.parentFile!!.mkdir()
160 | }
161 |
162 | // TODO handle when file already exists, but multiple transfers with same file name should be possible!
163 | // consider locking file access?
164 | // don't transfer file when it is the same exact file, check hash
165 | // handle other io and security errors
166 | val destinationFileSuccessfullyCreated: Boolean = destinationFile.createNewFile()
167 |
168 | if (!destinationFileSuccessfullyCreated) {
169 | Log.e(
170 | LOG_TAG, "TODO this MUST be handled! destination file (path: ${destinationFile.path}) to save received content to " +
171 | "not created because file with same name already exists! absolute path: ${destinationFile.absolutePath}"
172 | )
173 | }
174 |
175 | val fileOutputStreamToDestinationFile: FileOutputStream
176 | try {
177 | fileOutputStreamToDestinationFile = FileOutputStream(destinationFile)
178 | } catch (e: Exception) {
179 | when (e) {
180 | is FileNotFoundException, is SecurityException -> {
181 | // !!!
182 | // THIS CODE SHOULD NEVER BE REACHED!
183 | //
184 | // This should never happen as the path to the file is internally constructed in the app only influenced by the received name
185 | // for the content. The constructed path should always be existent and writable as it is in an app private directory. So neither a
186 | // FileNotFoundException nor a SecurityException should be possible to be thrown.
187 | // !!!
188 | Log.wtf(LOG_TAG, "Error on opening file in app private directory, which should always be possible! " +
189 | "App is in an undefined state, trying to exit app...", e)
190 | // Something is severely wrong with the state of the app (maybe a remote device tried to attack us), so it should throw
191 | // an error which is NOT caught by some other code and exit the app, meaning we should not try to recover from an undefined state
192 | // or from an attack but we should fail safely by quitting
193 | TODO("how to guarantee exiting all of the app without something intercepting it? ensure exceptions from this method are not handled")
194 | }
195 | else -> throw e
196 | }
197 |
198 | }
199 |
200 |
201 | // TODO handle interrupted and partial transfers correctly, don't display / save
202 | // TODO this is user supplied data transferred over to this device, we can't trust its content, should we do some checks for its
203 | // content in general (anti-malware etc) or for methods and programs that use it like processing in this app, FileProvider, etc?
204 | val contentSuccessfullyReceived: Boolean =
205 | copyBetweenByteStreamsAndFlush(inputStreamFromConnectedDevice, fileOutputStreamToDestinationFile)
206 |
207 |
208 | if (contentSuccessfullyReceived) {
209 | Log.i(
210 | LOG_TAG, "File name and content successfully received and written, error on resources closing " +
211 | "might still occurred"
212 | )
213 | }
214 |
215 | // close the outermost stream to call its flush method before it in turn closes any underlying streams and used resources
216 | try {
217 |
218 |
219 | // TODO why is socket to connected client not closed after closing input stream of it, is that a problem?
220 | // is it really not? check if it is, maybe also try to close the socket
221 | dataInputStream.close()
222 |
223 | Log.d(LOG_TAG, "socket to connected client closed state after closing dataInputStream: ${socketToConnectedClient.isClosed}")
224 |
225 | fileOutputStreamToDestinationFile.close()
226 |
227 |
228 | // closes the server socket, which is still bound to the ip address and port to receive connections with `accept()`
229 | // this releases its resources and makes it possible to bind to this port and ip address again by this or other apps
230 | // This is separate from the socket to the connected client.
231 | serverSocket.close()
232 | Log.d(LOG_TAG, "Successfully closed In- and Output streams and the server socket")
233 | } catch (e: IOException) {
234 | // error when closing streams and socket
235 | // TODO should this be ignored and is it bad when e.g. the output stream to the saved file has an error on close? just check
236 | // integrity of received content after writing to file (not only when in memory)? still resource leak?
237 | Log.e(LOG_TAG, "IO Error when closing In- or Output streams or the server socket!", e)
238 | TODO("Handle error on closing server socket appropriately")
239 | }
240 |
241 |
242 | // the ip address of the connected client will still be available after the socket is closed again and the client is disconnected
243 | // it will only be null when the client was never connected, for example when an error occurred and was caught before the connection
244 | return Pair(destinationFile.absolutePath, socketToConnectedClient.inetAddress)
245 | }
246 |
247 |
248 | private fun File.doesNotExist(): Boolean = !exists()
249 |
250 | /**
251 | * This method runs in the UI thread so we can safely modify our ui according to the result here.
252 | * Start activity that can handle opening the file
253 | */
254 | override fun onPostExecute(absolutePathReceivedFileAndClientIpAddress: Pair): Unit {
255 | val absolutePathReceivedFile: String? = absolutePathReceivedFileAndClientIpAddress.first
256 | val clientIpAddress: InetAddress? = absolutePathReceivedFileAndClientIpAddress.second
257 |
258 |
259 | // is this too fragile and the ip address the other device uses to connect to us over wifi direct could change so we could not
260 | // use it to connect back to them?
261 | // only update info about other device with new ip address if the other device actually connected to us and thus we have the (new)
262 | // ip address
263 | if (clientIpAddress != null) {
264 | Log.i(
265 | LOG_TAG, "Saving IP Address $clientIpAddress from WiFi Direct group client device after it connected to us" +
266 | ", AsyncTask with onlyGetNotifiedOfIpAddress: $onlyGetNotifiedOfIpAddress"
267 | )
268 | mainActivity.connectedClientWiFiDirectIpAddress = clientIpAddress
269 | }
270 |
271 | absolutePathReceivedFile?.let { absolutePathReceivedFile ->
272 |
273 |
274 | Log.d(LOG_TAG, "File was written to $absolutePathReceivedFile")
275 | statusText.text = "File received: $absolutePathReceivedFile"
276 |
277 |
278 | val uriToFile: Uri = Uri.parse("file://$absolutePathReceivedFile")
279 |
280 | // TODO this is ugly, extract method as general helper (for a context, because of content provider) or just globally
281 | // TODO change to display info with other method cause this only works with the content:// scheme and not file:// urls!
282 | mainActivity.dumpContentUriMetaData(uriToFile)
283 |
284 |
285 | // open saved file for viewing in a supported app
286 |
287 | val viewIntent: Intent = Intent(Intent.ACTION_VIEW)
288 |
289 | // use content provider for content:// uri scheme used in newer versions for modern working secure sharing of files with other apps,
290 | // but not all apps might support those instead of file:// uris, so still use them for older versions where they work for greater
291 | // compatibility
292 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
293 | // needed for app displaying the file having the temporary access to read from this uri, either uri must be put in data of intent
294 | // or `Context.grantUriPermission` must be called for the target package
295 | // explain in comments why this is needed / what exactly is needed more clearly
296 | viewIntent.flags = Intent.FLAG_GRANT_READ_URI_PERMISSION
297 |
298 | // the authority argument of `getUriForFile` must be the same as the authority of the file provider defined in the AndroidManifest!
299 | // should extract authority in global variable or resource to reuse in manifest and code
300 | val fileProviderUri =
301 | FileProvider.getUriForFile(mainActivity, BuildConfig.APPLICATION_ID + ".provider", File(absolutePathReceivedFile))
302 |
303 | // normalizing the uri to match android best practices for schemes: makes the scheme component lowercase
304 | viewIntent.setDataAndNormalize(fileProviderUri)
305 | } else {
306 | // normalizing the uri to match android case-sensitive matching for schemes: makes the scheme component lowercase
307 | viewIntent.setDataAndNormalize(uriToFile)
308 | }
309 |
310 | try {
311 | mainActivity.startActivity(viewIntent)
312 | } catch (e: ActivityNotFoundException) {
313 | Log.w(LOG_TAG, "No installed app supports viewing this content!", e)
314 | Snackbar.make(
315 | mainActivity.root_coordinator_layout,// could also be that the other device has not connected to us yet but the wifi direct connection is fine
316 | mainActivity.getString(R.string.could_not_find_activity_to_handle_viewing_content),
317 | Snackbar.LENGTH_LONG
318 | ).show()
319 | }
320 |
321 | }
322 | Log.i(LOG_TAG, "AsyncTask exited that was started with onlyGetNotifiedOfIpAddress: $onlyGetNotifiedOfIpAddress")
323 | }
324 | }
325 |
326 |
327 | // use IntentService for now for serial handling of requests on a worker thread without handling
328 | // thread creation ourselves, also Android Jobs are not
329 | // guaranteed to be executed immediately maybe (if app not in foreground at least?) and seem to have a execution limit of running
330 | // for 10 min, at least if not using: https://developer.android.com/topic/libraries/architecture/workmanager/advanced/long-running
331 | // Convert to foreground raw (not intent) service because deprecated
332 | @ExperimentalTime
333 | class SendFileOrNotifyOfIpAddressIntentService : IntentService(SendFileOrNotifyOfIpAddressIntentService::class.simpleName) {
334 |
335 | companion object {
336 |
337 | const val ACTION_NOTIFY_OF_IP_ADDRESS: String = "${BuildConfig.APPLICATION_ID}.wifi_direct.NOTIFY_OF_IP_ADDRESS"
338 |
339 | const val ACTION_SEND_FILE: String = "${BuildConfig.APPLICATION_ID}.wifi_direct.SEND_FILE"
340 |
341 | const val EXTRAS_OTHER_DEVICE_IP_ADDRESS: String = "other_device_ip_address"
342 |
343 |
344 | private const val ONGOING_NOTIFICATION_ID: Int = 1
345 |
346 | private const val PENDING_INTENT_FOR_NOTIFICATION_REQUEST_CODE: Int = 1
347 |
348 | private const val LOG_TAG: String = "SendFileOrIpService"
349 |
350 | // handle retry better
351 | private const val SOCKET_TIMEOUT_MILLISECONDS: Int = 30_000
352 |
353 |
354 | }
355 |
356 | // this is called with an intent to start work, if service started again this method will only be called when the first one finishes
357 | // runs in background thread and not in UI thread
358 | // intent is null if service was restarted after its process has gone away
359 | override fun onHandleIntent(workIntent: Intent?): Unit {
360 | Log.i(LOG_TAG, "onHandleIntent started")
361 |
362 |
363 | // promote this service to a foreground service to display an ongoing notification and keep running when the app gets in the background
364 |
365 | val startMainActivityPendingIntent: PendingIntent =
366 | Intent(this, MainActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK).let { startMainActivityExplicitIntent ->
367 | PendingIntent.getActivity(this, PENDING_INTENT_FOR_NOTIFICATION_REQUEST_CODE, startMainActivityExplicitIntent, 0)
368 | }
369 |
370 | // make sure the notification channel is created for Android 8+ before using it to post a notification
371 | createWiFiDirectConnectionNotificationChannelIfSupported(this)
372 |
373 | val notification: Notification = NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID_WIFI_DIRECT_CONNECTION)
374 | .setContentTitle(getText(R.string.wifi_direct_data_transfer_notification_title))
375 | //.setContentText(getText(R.string.wifi_direct_data_transfer_notification_message))
376 | //.setSmallIcon(R.drawable.icon)
377 | .setContentIntent(startMainActivityPendingIntent)
378 | .setTicker(getText(R.string.wifi_direct_data_transfer_ticker_text))
379 | // The priority determines how intrusive the notification should be on Android 7.1 and lower.
380 | // For Android 8.0 and higher, the priority depends on the channel importance of the notification channel
381 | // used above in the constructor.
382 | .setPriority(NotificationCompat.PRIORITY_DEFAULT)
383 | .build()
384 |
385 | startForeground(ONGOING_NOTIFICATION_ID, notification)
386 |
387 |
388 | val durationToSleep = 5.seconds
389 | Log.d(LOG_TAG, "Sleeping for $durationToSleep...")
390 | Thread.sleep(durationToSleep.toLongMilliseconds())
391 | Log.d(LOG_TAG, "Woke up again after $durationToSleep!")
392 |
393 |
394 | // Gets data from the incoming Intent
395 |
396 | val intentAction: String =
397 | workIntent?.action ?: TODO("handle error, service to connect to server was called without an action in the intent")
398 | if (intentAction !in listOf(ACTION_NOTIFY_OF_IP_ADDRESS, ACTION_SEND_FILE)) {
399 | TODO("handle error, service to connect to server was called with unknown action: $intentAction")
400 | }
401 | Log.i(LOG_TAG, "${this::class.simpleName} started with action: $intentAction")
402 | val otherDevicePort: Int = if (intentAction == ACTION_NOTIFY_OF_IP_ADDRESS) {
403 | SERVER_PORT_NOTIFY_OF_IP_ADDRESS
404 | } else { // action is sending a file, we checked earlier that only these 2 actions are possible
405 | SERVER_PORT_FILE_TRANSFER
406 | }
407 |
408 | val otherDeviceIpAddress: String = workIntent.getStringExtra(EXTRAS_OTHER_DEVICE_IP_ADDRESS)
409 | ?: TODO("handle error, send file service called without providing non-null ip address to connect to")
410 |
411 | val dataToSendUri: Uri? = workIntent.data
412 | if (intentAction == ACTION_SEND_FILE && dataToSendUri == null) {
413 | TODO(
414 | "handle error, send file service called with intent with ACTION_SEND_FILE but " +
415 | "without intent data, which should contain the uri of the data to send"
416 | )
417 | }
418 | // if we got here either we only notify of our ip address and do not use the uri of the data to send or it is not null and can be used
419 | // normally
420 |
421 |
422 | val socket: Socket = Socket()
423 |
424 | try {
425 | // bind with an ip address and port given to us by the system cause we initiate the connection and don't care for the outgoing port
426 | // and ip address
427 | // "If the address is null, then the system will pick up an ephemeral port and a valid local address to bind the socket."
428 | // If not passing null, meaning specifying port and ip, we might need to catch an IllegalArgumentException!
429 | Log.d(
430 | LOG_TAG, "Binding socket to ephemeral local ip address and port because we do not care for the source address " +
431 | "and port..."
432 | )
433 | socket.bind(null)
434 | val localBoundIpAddress: InetAddress = socket.localAddress
435 | if (localBoundIpAddress.isAnyLocalAddress && (socket.isClosed || !socket.isBound)) {
436 | Log.i(
437 | LOG_TAG, "Bound socket to port ${socket.localPort} but socket closed (closed state: ${socket.isClosed}) or\n" +
438 | "not bound yet (bound state: ${socket.isBound}) or closed (closed state: ${socket.isClosed}) so ip address is not " +
439 | "known anymore (it is displayed as the wildcard ip address: ${socket.localAddress})"
440 | )
441 | } else {
442 | Log.i(LOG_TAG, "Successfully bound socket to local ip address ${socket.localAddress} on port ${socket.localPort}")
443 | }
444 | // TODO handle connection timing out
445 | // blocks until connection established or error (e.g. timeout)
446 | socket.connect((InetSocketAddress(otherDeviceIpAddress, otherDevicePort)), SOCKET_TIMEOUT_MILLISECONDS)
447 | Log.i(
448 | LOG_TAG, "Successfully connected to other device with remote ip address ${socket.inetAddress} on " +
449 | "remote port ${socket.port}"
450 | )
451 |
452 |
453 | // If action is ACTION_NOTIFY_OF_IP_ADDRESS it is enough to connect to the other device, they will see the ip address from which
454 | // the connection is coming and can save it to use it to connect to this device.
455 | // Now follows the whole sending file name and data over the socket and opening and closing all output and input streams,
456 | // in the case of only notifying of the ip address wo do not need to open any input or output stream but just close the socket after
457 | // we successfully connected to the other device.
458 | if (intentAction == ACTION_SEND_FILE) {
459 | // Currently, we first send the file name and then the raw file data. These data (name and content) are then retrieved by the server
460 | // socket.
461 | val outputStreamConnectedDevice: OutputStream = socket.getOutputStream()
462 |
463 |
464 | val bufferedOutputStream: BufferedOutputStream = BufferedOutputStream(outputStreamConnectedDevice)
465 | val dataOutputStream: DataOutputStream = DataOutputStream(bufferedOutputStream)
466 |
467 | // assert non-null because if we got here ACTION_SEND_FILE was the action and if dataUri would have been null at the same time, we
468 | // would have thrown an error and exited earlier and not even got here
469 | // TODO don't use this name when it is an empty string or somehow invalid (contains slashes etc)?
470 | val displayName = getDisplayNameFromUri(this, dataToSendUri!!)
471 | Log.d(LOG_TAG, "Display name being sent is: $displayName")
472 |
473 |
474 | // this writes the length of the string and then a string of the display name of the content in "modified utf8"
475 | dataOutputStream.writeUTF(displayName)
476 | // Without this flush, the transfer will not work. Probably because the data output stream gets flushed on close and by then
477 | // the actual content is already written to the underlying output stream before the buffered data of the file name got written to it.
478 | // This results in the file name not being at the beginning of the data in the stream.
479 | // TODO But checking how many bytes were written on the underlying output stream suggests it is already written to it without the flush,
480 | // so no idea if this really was the problem and this is the appropriate fix... :/
481 | dataOutputStream.flush()
482 |
483 |
484 | // Create a byte stream from the file and copy it to the output stream of the socket.
485 | val inputStreamOfContentToTransfer: InputStream =
486 | contentResolver.openInputStream(dataToSendUri) ?: TODO("handle error, cannot get stream of data from chosen uri of content")
487 | val contentSuccessfullyTransferred: Boolean =
488 | copyBetweenByteStreamsAndFlush(inputStreamOfContentToTransfer, outputStreamConnectedDevice)
489 |
490 | if (contentSuccessfullyTransferred) {
491 | Log.i(LOG_TAG, "File name and content successfully transferred, error on resources closing might still occur")
492 | }
493 |
494 | // only close outermost stream here because it will flush its content and also close all underlying streams, which in turn flushes
495 | // them, as well as releasing all used resources
496 | dataOutputStream.close()
497 |
498 | inputStreamOfContentToTransfer.close()
499 |
500 | }
501 | } catch (e: FileNotFoundException) {
502 | Log.e(LOG_TAG, "File Not Found error when transferring content or ip address", e)
503 | TODO("handle file not found error when transferring data / connecting appropriately")
504 | } catch (e: IOException) {
505 | Log.e(LOG_TAG, "IO error when transferring content or ip address", e)
506 | TODO("handle io error while transferring data / connecting appropriately, intent action: $intentAction")
507 | } finally {
508 | // Clean up any open sockets when done
509 | // transferring or if an exception occurred.
510 | // TODO close outermost stream here in finally instead to also close when exception while transferring (going in catch clause)
511 | // or not important to also close outputstream and not only underlying socket cause data is lost either way and real resource
512 | // is only the socket and other streams will just be
513 | // cleaned up when out of scope?? but why not here? seems more clean
514 | if (socket.isConnected) {
515 | // TODO handle exception, keep exceptions from catch close by encapsulating or sth (adding to suppressed exceptions list)
516 | // so they are still viewable?
517 | try {
518 | socket.close()
519 | } catch (e: IOException) {
520 | Log.e(LOG_TAG, "IO Error while closing socket which was used to connect to the other device", e)
521 | }
522 | }
523 | }
524 | Log.d(LOG_TAG, "End of onHandleIntent of ${this::class.simpleName} started with intent action: $intentAction")
525 | }
526 | }
527 |
528 |
529 | fun copyBetweenByteStreamsAndFlush(inputStream: InputStream, outputStream: OutputStream): Boolean {
530 | // good buffer size for speed? just use `BufferedOutputStream` and `BufferedInputStream` instead of own buffer?
531 | // this only determines how much is read and then written to the output stream before new data is read, so this does not limit
532 | // transferable file size
533 | val buffer = ByteArray(1024)
534 | var totalNumberOfBytesRead: Int = 0
535 | var numberOfBytesReadAtOnce: Int
536 | try {
537 | // read until there is no more data because the end of the stream has been reached
538 | while (inputStream.read(buffer).also { numberOfBytesReadAtOnce = it } != -1) {
539 | totalNumberOfBytesRead += numberOfBytesReadAtOnce
540 | //Log.d(LOG_TAG, "number bytes read at once: $numberOfBytesReadAtOnce")
541 |
542 | // read bytes from buffer and write to the output stream with 0 offset
543 | outputStream.write(buffer, 0, numberOfBytesReadAtOnce)
544 | }
545 | // flush stream after writing data in case it was called with a buffered output stream and not a raw
546 | // output stream (where flushing would do nothing)
547 | Log.i(LOG_TAG, "Total number of bytes read and written: ${String.format("%,d", totalNumberOfBytesRead)}")
548 | outputStream.flush()
549 | } catch (e: IOException) {
550 | Log.e(LOG_TAG, "TODO handle io errors while sending bytes to the output stream", e)
551 | // TODO return number of bytes read or sth like -1 on error instead of simple true false or take expected number of bytes as argument
552 | // to check against it for errors (in future compare against returned hash or at least send hash so other
553 | // device can check or sth like that)
554 | // just use some library for the whole transfer stuff after we are connected?
555 | return false
556 | }
557 | return true
558 | }
559 |
560 |
561 | fun getDisplayNameFromUri(context: Context, uri: Uri): String {
562 |
563 | var displayName: String? = null
564 |
565 |
566 | // use projection parameter of query for performance?
567 | // this returns null if the uri does not use the content:// scheme
568 | val cursor: Cursor? = context.contentResolver.query(uri, null, null, null, null, null)
569 |
570 | if (cursor?.moveToFirst() == true) {
571 | // Note it's called "Display Name". This is
572 | // provider-specific, and might not necessarily be the file name.
573 | val columnIndex: Int = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
574 | if (columnIndex == -1) {
575 | Log.w(LOG_TAG, "There is no DISPLAY_NAME for the queried uri in the associated content resolver. Queried uri: $uri")
576 | } else {
577 | try {
578 | val displayNameFromContentResolver: String? = cursor.getString(columnIndex)
579 | if (displayNameFromContentResolver == null) {
580 | Log.w(LOG_TAG, "")
581 | Log.w(LOG_TAG, "DISPLAY_NAME value null returned from content resolver associated with uri: $uri")
582 | } else {
583 | // TODO don't use this value when it is an empty string?
584 | displayName = displayNameFromContentResolver
585 | }
586 |
587 | // depending on implementation, `cursor.getString()` might throw an exception when column value is not a String or null
588 | } catch (e: Exception) {
589 | Log.w(
590 | LOG_TAG,
591 | "Error when getting DISPLAY_NAME, even though the column exists in content resolver associated with uri: $uri",
592 | e
593 | )
594 | }
595 | }
596 | }
597 | cursor?.close()
598 |
599 | // fallback name if we could not get a display name from the content resolver
600 | if (displayName == null) {
601 | Log.w(LOG_TAG, "Couldn't get name from content provider, falling back to extracting it from the uri itself! uri: $uri")
602 | // uri.toString() is guaranteed to not return null, so we always return a non-null string, but possibly empty
603 | // TODO change this (that we might return empty string)
604 | // TODO we might have slashes in the name with schemes being prepended, not good for saving on
605 | // the other side when receiving this name
606 | displayName = uri.lastPathSegment ?: uri.encodedPath ?: uri.toString()
607 | }
608 |
609 | return displayName
610 |
611 | }
612 |
613 |
614 | fun createWiFiDirectConnectionNotificationChannelIfSupported(context: Context): Unit {
615 | // Create the NotificationChannel, but only on API 26+ because
616 | // the NotificationChannel class is new and not in the support library
617 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
618 | val name: String = context.getString(R.string.data_transfer_notification_channel_name)
619 | val descriptionText: String = context.getString(R.string.data_transfer_notification_channel_description)
620 | val importance: Int = NotificationManager.IMPORTANCE_DEFAULT
621 | val channel: NotificationChannel = NotificationChannel(NOTIFICATION_CHANNEL_ID_WIFI_DIRECT_CONNECTION, name, importance).apply {
622 | description = descriptionText
623 | }
624 | // Register the channel with the system
625 | // We do not need the compat version here because this is only needed on API 26+ where this function is always available,
626 | // but we want to use AndroidX code as much as possible to benefit from updates and fixes to it.
627 | NotificationManagerCompat.from(context).createNotificationChannel(channel)
628 | }
629 | }
630 |
631 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable-v24/ic_launcher_foreground.xml:
--------------------------------------------------------------------------------
1 |
7 |
12 |
13 |
19 |
22 |
25 |
26 |
27 |
28 |
34 |
35 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_launcher_background.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 |
10 |
15 |
20 |
25 |
30 |
35 |
40 |
45 |
50 |
55 |
60 |
65 |
70 |
75 |
80 |
85 |
90 |
95 |
100 |
105 |
110 |
115 |
120 |
125 |
130 |
135 |
140 |
145 |
150 |
155 |
160 |
165 |
170 |
171 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/activity_main.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
8 |
14 |
15 |
17 |
21 |
22 |
23 |
24 |
38 |
39 |
50 |
51 |
62 |
63 |
68 |
78 |
79 |
80 |
81 |
82 |
83 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/fragment_device.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
13 |
14 |
15 |
20 |
21 |
27 |
28 |
34 |
35 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/fragment_device_list.xml:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-hdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Rubberquacks/Fileshare/7e4948e1492ef5042a9eb100923b651a7692a7a2/app/src/main/res/mipmap-hdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-hdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Rubberquacks/Fileshare/7e4948e1492ef5042a9eb100923b651a7692a7a2/app/src/main/res/mipmap-hdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-mdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Rubberquacks/Fileshare/7e4948e1492ef5042a9eb100923b651a7692a7a2/app/src/main/res/mipmap-mdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-mdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Rubberquacks/Fileshare/7e4948e1492ef5042a9eb100923b651a7692a7a2/app/src/main/res/mipmap-mdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xhdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Rubberquacks/Fileshare/7e4948e1492ef5042a9eb100923b651a7692a7a2/app/src/main/res/mipmap-xhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Rubberquacks/Fileshare/7e4948e1492ef5042a9eb100923b651a7692a7a2/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxhdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Rubberquacks/Fileshare/7e4948e1492ef5042a9eb100923b651a7692a7a2/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Rubberquacks/Fileshare/7e4948e1492ef5042a9eb100923b651a7692a7a2/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Rubberquacks/Fileshare/7e4948e1492ef5042a9eb100923b651a7692a7a2/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Rubberquacks/Fileshare/7e4948e1492ef5042a9eb100923b651a7692a7a2/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/app/src/main/res/values/colors.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | #008577
4 | #00574B
5 | #D81B60
6 |
7 |
--------------------------------------------------------------------------------
/app/src/main/res/values/dimens.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | 16dp
4 |
5 |
--------------------------------------------------------------------------------
/app/src/main/res/values/strings.xml:
--------------------------------------------------------------------------------
1 |
2 | Fileshare
3 | Wi-Fi Direct: Enabled
4 | Wi-Fi Direct: Disabled
5 | Open File
6 | Wi-Fi Direct: Unknown Status
7 | You have to grant Location Permission to use Wi-Fi Direct
8 |
9 | Initiation of Wi-Fi Direct connection to %1$s %2$s failed: %3$s
10 |
12 | Wi-Fi Direct Connection %1$s with owner %2$s %3$s established
13 |
14 | (this device)
15 |
16 | (other device)
17 | Click on a receiver device first and wait for a successful connection
18 | Connection to device lost, connect again by clicking on a device
19 | Sends the requested file to the connected Wi-Fi Direct device. Keeps running in the background, so the file transfer does not cancel when one navigates away from the app.
20 | Wi-Fi Direct File Sending Service
21 | Enable Location Services to discover nearby Wi-Fi Direct devices
22 | Enable Wi-Fi to use Wi-Fi Direct
23 | No installed app supports viewing this content!
24 | Sending data over Wi-Fi Direct
25 | Sending data over Wi-Fi Direct
26 | Wi-Fi Direct Connection
27 | Displays a notification when data is currently transferred to another device.
28 | Waiting for other device to connect to receive its IP Address...
29 | Disconnect
30 |
31 |
--------------------------------------------------------------------------------
/app/src/main/res/values/styles.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/app/src/main/res/xml/content_provider_sharable_file_paths.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
--------------------------------------------------------------------------------
/app/src/test/java/dev/akampf/fileshare/ExampleUnitTest.kt:
--------------------------------------------------------------------------------
1 | package dev.akampf.fileshare
2 |
3 | import org.junit.Test
4 |
5 | import org.junit.Assert.*
6 |
7 | /**
8 | * Example local unit test, which will execute on the development machine (host).
9 | *
10 | * See [testing documentation](http://d.android.com/tools/testing).
11 | */
12 | class ExampleUnitTest {
13 | @Test
14 | fun addition_isCorrect() {
15 | assertEquals(4, 2 + 2)
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/build.gradle:
--------------------------------------------------------------------------------
1 | // Top-level build file where you can add configuration options common to all sub-projects/modules.
2 |
3 | buildscript {
4 | ext.kotlin_version = '1.4.10'
5 | repositories {
6 | google()
7 | jcenter()
8 |
9 | }
10 | dependencies {
11 | classpath 'com.android.tools.build:gradle:4.2.0-alpha13'
12 | classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
13 |
14 | // NOTE: Do not place your application dependencies here; they belong
15 | // in the individual module build.gradle files
16 | }
17 | }
18 |
19 | allprojects {
20 | repositories {
21 | google()
22 | jcenter()
23 |
24 | }
25 | }
26 |
27 | task clean(type: Delete) {
28 | delete rootProject.buildDir
29 | }
30 |
--------------------------------------------------------------------------------
/gradle.properties:
--------------------------------------------------------------------------------
1 | # Project-wide Gradle settings.
2 | # IDE (e.g. Android Studio) users:
3 | # Gradle settings configured through the IDE *will override*
4 | # any settings specified in this file.
5 | # For more details on how to configure your build environment visit
6 | # http://www.gradle.org/docs/current/userguide/build_environment.html
7 | # Specifies the JVM arguments used for the daemon process.
8 | # The setting is particularly useful for tweaking memory settings.
9 | # Default was: org.gradle.jvmargs=-Xmx2048m
10 | org.gradle.jvmargs=-Xmx2048M -Dkotlin.daemon.jvm.options\="-Xmx2048M"
11 |
12 | # When configured, Gradle will run in incubating parallel mode.
13 | # This option should only be used with decoupled projects. More details, visit
14 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
15 | # org.gradle.parallel=true
16 |
17 | # Kotlin code style for this project: "official" or "obsolete":
18 | kotlin.code.style=official
19 |
20 | # Automatically convert third-party libraries to use AndroidX
21 | android.enableJetifier=true
22 |
23 | # AndroidX package structure to make it clearer which packages are bundled with the
24 | # Android operating system, and which are packaged with your app"s APK
25 | # https://developer.android.com/topic/libraries/support-library/androidx-rn
26 | android.useAndroidX=true
27 |
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.properties:
--------------------------------------------------------------------------------
1 | #Sun Oct 04 18:53:09 CEST 2020
2 | distributionBase=GRADLE_USER_HOME
3 | distributionUrl=https\://services.gradle.org/distributions/gradle-6.7-rc-3-bin.zip
4 | distributionPath=wrapper/dists
5 | zipStorePath=wrapper/dists
6 | zipStoreBase=GRADLE_USER_HOME
7 |
--------------------------------------------------------------------------------
/gradlew:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 |
3 | ##############################################################################
4 | ##
5 | ## Gradle start up script for UN*X
6 | ##
7 | ##############################################################################
8 |
9 | # Attempt to set APP_HOME
10 | # Resolve links: $0 may be a link
11 | PRG="$0"
12 | # Need this for relative symlinks.
13 | while [ -h "$PRG" ] ; do
14 | ls=`ls -ld "$PRG"`
15 | link=`expr "$ls" : '.*-> \(.*\)$'`
16 | if expr "$link" : '/.*' > /dev/null; then
17 | PRG="$link"
18 | else
19 | PRG=`dirname "$PRG"`"/$link"
20 | fi
21 | done
22 | SAVED="`pwd`"
23 | cd "`dirname \"$PRG\"`/" >/dev/null
24 | APP_HOME="`pwd -P`"
25 | cd "$SAVED" >/dev/null
26 |
27 | APP_NAME="Gradle"
28 | APP_BASE_NAME=`basename "$0"`
29 |
30 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
31 | DEFAULT_JVM_OPTS=""
32 |
33 | # Use the maximum available, or set MAX_FD != -1 to use that value.
34 | MAX_FD="maximum"
35 |
36 | warn () {
37 | echo "$*"
38 | }
39 |
40 | die () {
41 | echo
42 | echo "$*"
43 | echo
44 | exit 1
45 | }
46 |
47 | # OS specific support (must be 'true' or 'false').
48 | cygwin=false
49 | msys=false
50 | darwin=false
51 | nonstop=false
52 | case "`uname`" in
53 | CYGWIN* )
54 | cygwin=true
55 | ;;
56 | Darwin* )
57 | darwin=true
58 | ;;
59 | MINGW* )
60 | msys=true
61 | ;;
62 | NONSTOP* )
63 | nonstop=true
64 | ;;
65 | esac
66 |
67 | CLASSPATH=${APP_HOME}/gradle/wrapper/gradle-wrapper.jar
68 |
69 | # Determine the Java command to use to start the JVM.
70 | if [ -n "$JAVA_HOME" ] ; then
71 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
72 | # IBM's JDK on AIX uses strange locations for the executables
73 | JAVACMD="$JAVA_HOME/jre/sh/java"
74 | else
75 | JAVACMD="$JAVA_HOME/bin/java"
76 | fi
77 | if [ ! -x "$JAVACMD" ] ; then
78 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
79 |
80 | Please set the JAVA_HOME variable in your environment to match the
81 | location of your Java installation."
82 | fi
83 | else
84 | JAVACMD="java"
85 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
86 |
87 | Please set the JAVA_HOME variable in your environment to match the
88 | location of your Java installation."
89 | fi
90 |
91 | # Increase the maximum file descriptors if we can.
92 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
93 | MAX_FD_LIMIT=`ulimit -H -n`
94 | if [ $? -eq 0 ] ; then
95 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
96 | MAX_FD="$MAX_FD_LIMIT"
97 | fi
98 | ulimit -n ${MAX_FD}
99 | if [ $? -ne 0 ] ; then
100 | warn "Could not set maximum file descriptor limit: $MAX_FD"
101 | fi
102 | else
103 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
104 | fi
105 | fi
106 |
107 | # For Darwin, add options to specify how the application appears in the dock
108 | if ${darwin}; then
109 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
110 | fi
111 |
112 | # For Cygwin, switch paths to Windows format before running java
113 | if ${cygwin} ; then
114 | APP_HOME=`cygpath --path --mixed "$APP_HOME"`
115 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
116 | JAVACMD=`cygpath --unix "$JAVACMD"`
117 |
118 | # We build the pattern for arguments to be converted via cygpath
119 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
120 | SEP=""
121 | for dir in ${ROOTDIRSRAW} ; do
122 | ROOTDIRS="$ROOTDIRS$SEP$dir"
123 | SEP="|"
124 | done
125 | OURCYGPATTERN="(^($ROOTDIRS))"
126 | # Add a user-defined pattern to the cygpath arguments
127 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then
128 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
129 | fi
130 | # Now convert the arguments - kludge to limit ourselves to /bin/sh
131 | i=0
132 | for arg in "$@" ; do
133 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
134 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
135 |
136 | if [ ${CHECK} -ne 0 ] && [ ${CHECK2} -eq 0 ] ; then ### Added a condition
137 | eval `echo args${i}`=`cygpath --path --ignore --mixed "$arg"`
138 | else
139 | eval `echo args${i}`="\"${arg}\""
140 | fi
141 | i=$((i+1))
142 | done
143 | case ${i} in
144 | (0) set -- ;;
145 | (1) set -- "$args0" ;;
146 | (2) set -- "$args0" "$args1" ;;
147 | (3) set -- "$args0" "$args1" "$args2" ;;
148 | (4) set -- "$args0" "$args1" "$args2" "$args3" ;;
149 | (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
150 | (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
151 | (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
152 | (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
153 | (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
154 | esac
155 | fi
156 |
157 | # Escape application args
158 | save () {
159 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
160 | echo " "
161 | }
162 | APP_ARGS=$(save "$@")
163 |
164 | # Collect all arguments for the java command, following the shell quoting and substitution rules
165 | eval set -- ${DEFAULT_JVM_OPTS} ${JAVA_OPTS} ${GRADLE_OPTS} "\"-Dorg.gradle.appname=${APP_BASE_NAME}\"" -classpath "\"${CLASSPATH}\"" org.gradle.wrapper.GradleWrapperMain "${APP_ARGS}"
166 |
167 | # by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong
168 | if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then
169 | cd "$(dirname "$0")"
170 | fi
171 |
172 | exec "$JAVACMD" "$@"
173 |
--------------------------------------------------------------------------------
/gradlew.bat:
--------------------------------------------------------------------------------
1 | @if "%DEBUG%" == "" @echo off
2 | @rem ##########################################################################
3 | @rem
4 | @rem Gradle startup script for Windows
5 | @rem
6 | @rem ##########################################################################
7 |
8 | @rem Set local scope for the variables with windows NT shell
9 | if "%OS%"=="Windows_NT" setlocal
10 |
11 | set DIRNAME=%~dp0
12 | if "%DIRNAME%" == "" set DIRNAME=.
13 | set APP_BASE_NAME=%~n0
14 | set APP_HOME=%DIRNAME%
15 |
16 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
17 | set DEFAULT_JVM_OPTS=
18 |
19 | @rem Find java.exe
20 | if defined JAVA_HOME goto findJavaFromJavaHome
21 |
22 | set JAVA_EXE=java.exe
23 | %JAVA_EXE% -version >NUL 2>&1
24 | if "%ERRORLEVEL%" == "0" goto init
25 |
26 | echo.
27 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
28 | echo.
29 | echo Please set the JAVA_HOME variable in your environment to match the
30 | echo location of your Java installation.
31 |
32 | goto fail
33 |
34 | :findJavaFromJavaHome
35 | set JAVA_HOME=%JAVA_HOME:"=%
36 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe
37 |
38 | if exist "%JAVA_EXE%" goto init
39 |
40 | echo.
41 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
42 | echo.
43 | echo Please set the JAVA_HOME variable in your environment to match the
44 | echo location of your Java installation.
45 |
46 | goto fail
47 |
48 | :init
49 | @rem Get command-line arguments, handling Windows variants
50 |
51 | if not "%OS%" == "Windows_NT" goto win9xME_args
52 |
53 | :win9xME_args
54 | @rem Slurp the command line arguments.
55 | set CMD_LINE_ARGS=
56 | set _SKIP=2
57 |
58 | :win9xME_args_slurp
59 | if "x%~1" == "x" goto execute
60 |
61 | set CMD_LINE_ARGS=%*
62 |
63 | :execute
64 | @rem Setup the command line
65 |
66 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
67 |
68 | @rem Execute Gradle
69 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
70 |
71 | :end
72 | @rem End local scope for the variables with windows NT shell
73 | if "%ERRORLEVEL%"=="0" goto mainEnd
74 |
75 | :fail
76 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
77 | rem the _cmd.exe /c_ return code!
78 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
79 | exit /b 1
80 |
81 | :mainEnd
82 | if "%OS%"=="Windows_NT" endlocal
83 |
84 | :omega
85 |
--------------------------------------------------------------------------------
/settings.gradle:
--------------------------------------------------------------------------------
1 | include ':app'
2 | rootProject.name='Fileshare'
3 |
--------------------------------------------------------------------------------