├── .gitignore ├── .idea ├── codeStyles │ ├── Project.xml │ └── codeStyleConfig.xml ├── compiler.xml ├── dictionaries │ └── xuiqzy.xml ├── gradle.xml ├── inspectionProfiles │ └── Project_Default.xml ├── jarRepositories.xml ├── misc.xml ├── render.experimental.xml ├── runConfigurations.xml └── vcs.xml ├── README.md ├── app ├── .gitignore ├── build.gradle ├── proguard-rules.pro └── src │ ├── androidTest │ └── java │ │ └── dev │ │ └── akampf │ │ └── fileshare │ │ └── ExampleInstrumentedTest.kt │ ├── main │ ├── AndroidManifest.xml │ ├── java │ │ └── dev │ │ │ └── akampf │ │ │ └── fileshare │ │ │ ├── Constants.kt │ │ │ ├── LocationModeChangedBroadcastReceiver.kt │ │ │ ├── MainActivity.kt │ │ │ ├── WiFiDirectBackgroundService.kt │ │ │ ├── WiFiDirectBroadcastReceiver.kt │ │ │ ├── WiFiDirectDeviceFragment.kt │ │ │ ├── WiFiDirectPeerDevicesRecyclerViewAdapter.kt │ │ │ └── WiFiDirectTransfer.kt │ └── res │ │ ├── drawable-v24 │ │ └── ic_launcher_foreground.xml │ │ ├── drawable │ │ └── ic_launcher_background.xml │ │ ├── layout │ │ ├── activity_main.xml │ │ ├── fragment_device.xml │ │ └── fragment_device_list.xml │ │ ├── mipmap-anydpi-v26 │ │ ├── ic_launcher.xml │ │ └── ic_launcher_round.xml │ │ ├── mipmap-hdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-mdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xxhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xxxhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── values │ │ ├── colors.xml │ │ ├── dimens.xml │ │ ├── strings.xml │ │ └── styles.xml │ │ └── xml │ │ └── content_provider_sharable_file_paths.xml │ └── test │ └── java │ └── dev │ └── akampf │ └── fileshare │ └── ExampleUnitTest.kt ├── build.gradle ├── gradle.properties ├── gradle └── wrapper │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat └── settings.gradle /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | /local.properties 4 | /.idea/caches 5 | /.idea/libraries 6 | /.idea/modules.xml 7 | /.idea/workspace.xml 8 | /.idea/navEditor.xml 9 | /.idea/assetWizardSettings.xml 10 | .DS_Store 11 | /build 12 | /captures 13 | .externalNativeBuild 14 | .cxx 15 | local.properties 16 | -------------------------------------------------------------------------------- /.idea/codeStyles/Project.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 146 | -------------------------------------------------------------------------------- /.idea/codeStyles/codeStyleConfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | -------------------------------------------------------------------------------- /.idea/compiler.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/dictionaries/xuiqzy.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | killable 5 | linter 6 | snackbar 7 | vorbis 8 | 9 | 10 | -------------------------------------------------------------------------------- /.idea/gradle.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 21 | 22 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/Project_Default.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 8 | -------------------------------------------------------------------------------- /.idea/jarRepositories.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | 10 | 14 | 15 | 19 | 20 | 24 | 25 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 9 | -------------------------------------------------------------------------------- /.idea/render.experimental.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | -------------------------------------------------------------------------------- /.idea/runConfigurations.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 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 |