diff --git a/.github/workflows/android-build.yml b/.github/workflows/android-build.yml new file mode 100644 index 0000000..b611b36 --- /dev/null +++ b/.github/workflows/android-build.yml @@ -0,0 +1,265 @@ +name: Android Benchmark Build & Release + +on: + push: + branches: + - test + tags: + - 'v*' + +env: + JAVA_VERSION: '21' + JAVA_DISTRIBUTION: 'temurin' + +jobs: + build: + runs-on: ubuntu-latest + name: Build APK + outputs: + version: ${{ steps.get-version.outputs.version }} + is-release: ${{ steps.check-release.outputs.is-release }} + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Check if this is a release + id: check-release + run: | + if [[ "${{ github.ref }}" == refs/tags/v* ]]; then + echo "is-release=true" >> $GITHUB_OUTPUT + else + echo "is-release=false" >> $GITHUB_OUTPUT + fi + + - name: Get version + id: get-version + run: | + if [[ "${{ github.ref }}" == refs/tags/v* ]]; then + version="${{ github.ref_name }}" + echo "version=$version" >> $GITHUB_OUTPUT + else + echo "version=test-${{ github.sha }}" >> $GITHUB_OUTPUT + fi + + - name: Set up JDK ${{ env.JAVA_VERSION }} + uses: actions/setup-java@v4 + with: + distribution: ${{ env.JAVA_DISTRIBUTION }} + java-version: ${{ env.JAVA_VERSION }} + + - name: Cache Gradle packages + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + gradle-${{ runner.os }}- + + - name: Grant execute permission for gradlew + run: chmod +x ./gradlew + + - name: Validate Gradle wrapper + uses: gradle/wrapper-validation-action@v2 + + # For releases, create signed APK; for test builds, create unsigned APK + - name: Build signed release APK + if: steps.check-release.outputs.is-release == 'true' + run: | + echo "Building signed release APK..." + ./gradlew :app:assembleBenchmarkRelease + env: + KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }} + KEY_ALIAS: ${{ secrets.KEY_ALIAS }} + KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }} + KEYSTORE_FILE: ${{ secrets.KEYSTORE_FILE }} + + - name: Build unsigned test APK + if: steps.check-release.outputs.is-release == 'false' + run: | + echo "Building unsigned test APK..." + ./gradlew :app:assembleBenchmarkRelease + + - name: Setup keystore for signing (Release only) + if: steps.check-release.outputs.is-release == 'true' + run: | + echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 --decode > keystore.jks + echo "Keystore file created" + + - name: Sign APK (Release only) + if: steps.check-release.outputs.is-release == 'true' + run: | + # Find the APK file + APK_FILE=$(find app/build/outputs/apk/benchmarkRelease -name "*.apk" | head -1) + if [ -z "$APK_FILE" ]; then + echo "Error: No APK file found" + exit 1 + fi + + echo "Found APK: $APK_FILE" + + # Sign the APK + $ANDROID_HOME/build-tools/*/apksigner sign \ + --ks keystore.jks \ + --ks-key-alias "${{ secrets.KEY_ALIAS }}" \ + --ks-pass pass:"${{ secrets.KEYSTORE_PASSWORD }}" \ + --key-pass pass:"${{ secrets.KEY_PASSWORD }}" \ + --out "${APK_FILE%.apk}-signed.apk" \ + "$APK_FILE" + + # Verify the signature + $ANDROID_HOME/build-tools/*/apksigner verify "${APK_FILE%.apk}-signed.apk" + + echo "APK signed and verified successfully" + + # Replace unsigned APK with signed one + mv "${APK_FILE%.apk}-signed.apk" "$APK_FILE" + + - name: Rename APK with version + run: | + APK_FILE=$(find app/build/outputs/apk/benchmarkRelease -name "*.apk" | head -1) + if [ -z "$APK_FILE" ]; then + echo "Error: No APK file found" + exit 1 + fi + + APK_DIR=$(dirname "$APK_FILE") + APK_NAME=$(basename "$APK_FILE" .apk) + VERSION="${{ steps.get-version.outputs.version }}" + + if [[ "${{ steps.check-release.outputs.is-release }}" == "true" ]]; then + NEW_NAME="ClipSync-Android-${VERSION}-signed.apk" + else + NEW_NAME="ClipSync-Android-${VERSION}-unsigned.apk" + fi + + mv "$APK_FILE" "$APK_DIR/$NEW_NAME" + echo "APK renamed to: $NEW_NAME" + echo "apk-path=$APK_DIR/$NEW_NAME" >> $GITHUB_OUTPUT + id: rename-apk + + - name: Get APK info + run: | + APK_FILE=$(find app/build/outputs/apk/benchmarkRelease -name "*.apk" | head -1) + if [ -n "$APK_FILE" ]; then + echo "APK Size: $(du -h "$APK_FILE" | cut -f1)" + echo "APK Path: $APK_FILE" + + # Get APK details using aapt if available + if command -v aapt >/dev/null 2>&1; then + echo "APK Package Info:" + aapt dump badging "$APK_FILE" | grep -E "package:|application-label:|platformBuildVersionName" + fi + fi + + - name: Cleanup keystore + if: always() && steps.check-release.outputs.is-release == 'true' + run: | + if [ -f keystore.jks ]; then + rm -f keystore.jks + echo "Keystore file cleaned up" + fi + + - name: Upload APK artifact + uses: actions/upload-artifact@v4 + with: + name: clipsync-android-${{ steps.get-version.outputs.version }} + path: app/build/outputs/apk/benchmarkRelease/*.apk + retention-days: ${{ steps.check-release.outputs.is-release == 'true' && 90 || 30 }} + + test-release: + needs: build + if: github.ref == 'refs/heads/test' + runs-on: ubuntu-latest + name: Test Release Upload + + steps: + - name: Download APK artifact + uses: actions/download-artifact@v4 + with: + name: clipsync-android-${{ needs.build.outputs.version }} + path: ./apk/ + + - name: List test artifacts + run: | + echo "Test build artifacts:" + find ./apk -name "*.apk" -exec ls -lh {} \; + + release: + needs: build + if: startsWith(github.ref, 'refs/tags/v') + runs-on: ubuntu-latest + name: Create GitHub Release + + steps: + - name: Download APK artifact + uses: actions/download-artifact@v4 + with: + name: clipsync-android-${{ needs.build.outputs.version }} + path: ./apk/ + + - name: Verify release artifacts + run: | + echo "Release artifacts:" + find ./apk -name "*.apk" -exec ls -lh {} \; + + # Verify we have signed APK for release + if ! find ./apk -name "*-signed.apk" | grep -q .; then + echo "Warning: No signed APK found for release" + else + echo "Signed APK found for release" + fi + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + name: ClipSync Android ${{ needs.build.outputs.version }} + body: | + # πŸŽ‰ Introducing ClipSync Android ${{ needs.build.outputs.version }} πŸŽ‰ + + Welcome to the very first official release of **ClipSync for Android**! πŸš€ + + ClipSync bridges your clipboard between Android and Windows devicesβ€”instantly and wirelessly. Copy text on your phone, paste it on your PC, and vice versa. No cloud, no ads, just seamless Bluetooth-powered productivity! + + ## What is ClipSync? + ClipSync is a privacy-first clipboard sharing app that connects your Android and Windows devices over Bluetooth. Effortlessly share text between your phone and computer with a single tapβ€”perfect for students, professionals, and anyone tired of emailing themselves snippets! + + - πŸ”„ **Instant clipboard sharing** between Android and Windows + - πŸ”’ **Private & local**: No data ever leaves your devices + - πŸŒ™ **Dark & Light themes** + - ⚑ **Optimized for speed** with our special benchmarkRelease build + + ## Installation + + 1. Download the APK file below + 2. Enable "Install from unknown sources" in your Android settings + 3. Install the APK file + 4. Pair with your Windows device (see README for details) + + ## System Requirements + - Android 7.0 (API level 24) or higher + - Bluetooth capability (for device synchronization) + + ## Security + This APK is signed with our release key for security and authenticity. Your clipboard data stays on your devicesβ€”no servers, no snooping. + + ## What's New + This is our **first release**! 🎊 + - Android ↔ Windows clipboard sync + - Bluetooth device discovery & pairing + - Service-based background listening + - Theme switching & intuitive UI + + For more details, check the [CHANGELOG](CHANGELOG.md). + + --- + Thank you for trying ClipSync! If you love it, star the repo and share feedback. Happy syncing! + + files: ./apk/*.apk + draft: false + prerelease: false + generate_release_notes: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/.gitignore b/.gitignore index aa724b7..a72f377 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,8 @@ .gradle /local.properties /.idea/caches +/.idea +/.kotlin /.idea/libraries /.idea/modules.xml /.idea/workspace.xml @@ -13,3 +15,5 @@ .externalNativeBuild .cxx local.properties +# gradlew +# gradle/wrapper/gradle-wrapper.jar diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md new file mode 100644 index 0000000..965d8ec --- /dev/null +++ b/DEVELOPER_GUIDE.md @@ -0,0 +1,224 @@ +# ClipSync Developer Guide + +## Architecture Overview + +ClipSync follows a service-oriented architecture designed for background clipboard synchronization: + +### Core Components + +1. **MainActivity**: Entry point for user interaction and service management +2. **BluetoothService**: Foreground service handling all Bluetooth operations +3. **Essentials**: Global state manager for service communication +4. **NotificationReceiver**: Broadcast receiver for notification actions + +### Design Patterns + +#### Service-Oriented Architecture + +- **BluetoothService** runs independently as a foreground service +- Survives Activity lifecycle for continuous background operation +- Communicates via the Essentials singleton for real-time updates + +#### Global State Management (Essentials Pattern) + +While typically considered an anti-pattern, the Essentials singleton is justified here because: + +- Service needs to persist beyond Activity lifecycle +- Multiple components require real-time service state access +- Avoids service restart overhead for configuration updates +- Properly cleaned up when service stops + +### Key Features + +- **Background Operation**: Service continues running when app is closed +- **Notification Control**: Start/stop service via persistent notification +- **Real-time Updates**: Update service configuration without restart +- **Thread Safety**: Mutex-protected state management + +## Development Setup + +### Prerequisites + +- Android Studio Arctic Fox or newer +- Android SDK 24+ (Android 7.0) +- Kotlin 1.8+ +- Gradle 8.0+ + +### Build Commands + +```bash +# Debug build +./gradlew assembleDebug + +# Release build +./gradlew assembleRelease + +# Run tests +./gradlew test + +# Run instrumented tests +./gradlew connectedAndroidTest +``` + +## Code Style Guidelines + +### Kotlin Conventions + +- Follow official Kotlin coding conventions +- Use meaningful variable names +- Document public APIs with KDoc +- Prefer immutable data structures + +### Architecture Guidelines + +- Keep Activities lightweight - delegate to services +- Use coroutines for async operations +- Implement proper error handling +- Follow single responsibility principle + +## Security Considerations + +### Data Validation + +- Validate clipboard content size (max 1MB) +- Check for potentially sensitive data patterns +- Sanitize device names for display + +### Bluetooth Security + +- Use secure RFCOMM connections +- Validate device addresses +- Implement connection timeouts + +### Privacy + +- No cloud storage - all data local +- Temporary clipboard storage only +- Clear sensitive data on service stop + +## Testing Strategy + +### Unit Tests + +- Core business logic +- Data validation +- State management + +### Integration Tests + +- Bluetooth service operations +- Notification handling +- Permission management + +### Manual Testing Checklist + +- [ ] Service starts/stops correctly +- [ ] Clipboard sync between devices +- [ ] Notification actions work +- [ ] Permission handling +- [ ] Battery optimization compatibility + +## Performance Optimizations + +### Memory Management + +- Use weak references where appropriate +- Clean up resources in onDestroy() +- Avoid memory leaks in long-running service + +### Battery Optimization + +- Efficient Bluetooth operations +- Minimize wake locks +- Use appropriate service types + +### Network Efficiency + +- Connection pooling for multiple devices +- Retry logic with exponential backoff +- Timeout handling + +## Debugging Tips + +### Logging + +- Use structured logging with tags +- Different log levels for debug/release +- Include relevant context in logs + +### Common Issues + +1. **Service not starting**: Check permissions and Bluetooth state +2. **Devices not connecting**: Verify pairing and RFCOMM setup +3. **Memory leaks**: Use LeakCanary for detection +4. **Battery drain**: Profile with Android Studio + +## Release Process + +### Version Management + +- Update versionCode and versionName in build.gradle.kts +- Follow semantic versioning (MAJOR.MINOR.PATCH) +- Update CHANGELOG.md + +### Build Configuration + +- Use release build type for production +- Enable ProGuard/R8 optimization +- Test thoroughly before release + +### GitHub Release Steps + +1. Create release branch +2. Update version numbers +3. Run full test suite +4. Build signed APK +5. Create GitHub release with changelog +6. Upload APK to release + +## Contributing + +### Code Review Checklist + +- [ ] Code follows style guidelines +- [ ] Tests added for new features +- [ ] Documentation updated +- [ ] No breaking changes without migration +- [ ] Performance impact considered + +### Pull Request Template + +- Clear description of changes +- Link to related issues +- Screenshots for UI changes +- Test results included + +## Troubleshooting + +### Build Issues + +- Clean and rebuild project +- Invalidate caches and restart +- Check Gradle and SDK versions + +### Runtime Issues + +- Check device logs with `adb logcat` +- Verify permissions in device settings +- Test on different Android versions + +## Future Enhancements + +### Planned Features + +- File sharing support +- Encryption for sensitive data +- Multi-device group management +- Cross-platform compatibility + +### Technical Debt + +- Migrate to Bluetooth LE for better efficiency +- Implement proper dependency injection +- Add comprehensive error recovery +- Improve test coverage diff --git a/README.md b/README.md new file mode 100644 index 0000000..3ab84c7 --- /dev/null +++ b/README.md @@ -0,0 +1,216 @@ + +
+ +# ClipSync + +ClipSync Logo + +**Share your clipboard instantly across Android and Windows devices via Bluetooth** + +[![Android](https://img.shields.io/badge/Platform-Android-green.svg)](https://android.com) +[![License](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) +
+ +--- + +## πŸš€ What is ClipSync? + +ClipSync is an Android application that enables seamless clipboard sharing between your +Android and Windows devices using Bluetooth technology. Copy text on one device and instantly access it on +another - no internet connection required! + +### ✨ Key Features + +- **πŸ”— Instant Clipboard Sharing**: Copy text on one device, access it immediately on paired devices +- **πŸ“± Background Operation**: Works silently in the background - no need to keep the app open +- **πŸ”΅ Bluetooth-Based**: Uses secure Bluetooth RFCOMM protocol - no internet required +- **⚑ Auto-Copy**: Automatically copies received text to your clipboard - can be turned off +- **πŸ”” Smart Notifications**: Get notified when new clipboard content arrives - when auto-copy is off +- **πŸŒ™ Dark Mode Support**: Beautiful interface that adapts to your system theme +- **πŸ”’ Privacy-First**: All data stays between your devices - no cloud storage + +--- + +## πŸ“± How It Works + +1. **Pair Your Devices**: Connect your Android devices via Bluetooth +2. **Select Devices**: Choose which paired devices to share clipboard with +3. **Start Sharing**: Enable the ClipSync service with one tap +4. **Copy & Share**: Copy text on any device and press share from the notification action button +5. **Background**: ClipSync works in the background, even when the app is closed + +--- + +## πŸ› οΈ Installation + +### Download Options + +#### Option 1: GitHub Releases (Recommended) + +1. Go to [Releases](../../releases) +2. Download the latest `ClipSync-v1.0.0.apk` file +3. Enable "Install from Unknown Sources" in your Android settings +4. Install the APK file + +#### Option 2: Build from Source + +```bash +git clone https://github.com/aubynsamuel/clipSync-android.git +cd clipSync-android +./gradlew assembleRelease +``` + +--- + +## πŸ–₯️ Windows Companion App + +To sync your clipboard between Android and Windows, you need the ClipSync Windows Companion App: + +➑️ [**Download or build it from here**](https://github.com/aubynsamuel/clipsync-windows.git) + +Follow the instructions in the Windows app README to set up and pair with your Android device. + +--- + +## 🎯 Quick Start Guide + +### First Time Setup + +1. **Install ClipSync** on all devices you want to share text with +2. **Enable Bluetooth** on all devices +3. **Pair your devices** through Android Bluetooth settings +4. **Open ClipSync** and grant necessary permissions + +### Using ClipSync + +#### Starting Clipboard Sharing + +1. Open ClipSync +2. Select the devices you want to share with +3. Tap "Start Sharing" +4. The service will run in the background + +#### Sharing Clipboard Content + +1. Copy any text on your device (long press β†’ Copy) +2. The text automatically appears on your paired devices +3. If Auto-Copy is enabled, it's instantly available in the clipboard +4. If Auto-Copy is disabled, you'll get a notification to manually copy + +#### Managing the Service + +- **View Status**: Check the persistent notification +- **Stop Service**: Tap "Dismiss" in the notification or use the stop button in the app +- **Update Settings**: Change Auto-Copy mode or selected devices anytime + +--- + +## βš™οΈ Settings & Configuration + +### Auto-Copy Mode + +- **Enabled**: Received text is automatically copied to your clipboard +- **Disabled**: You'll receive a notification to manually copy the text + +### Device Selection + +- Choose which paired Bluetooth devices to share clipboard with +- You can update your selection anytime without restarting the service + +### Theme Options + +- **Light Mode**: Clean, bright interface +- **Dark Mode**: Easy on the eyes, battery-friendly + +--- + +## πŸ”’ Privacy & Security + +ClipSync is designed with privacy in mind: + +- **Local Communication**: All data transfers happen directly between your devices via Bluetooth +- **No Cloud Storage**: Your clipboard content never leaves your devices +- **No Internet Required**: Works completely offline +- **Temporary Storage**: Clipboard content is not permanently stored +- **Secure Protocol**: Uses Bluetooth RFCOMM with built-in security features + +### Security Best Practices + +- Only pair with devices you trust +- Be mindful when sharing sensitive information + +--- + +## πŸ”§ Troubleshooting + +### Common Issues + +#### Devices Not Connecting + +- Ensure both devices have Bluetooth enabled +- Check that devices are properly paired in Android Bluetooth settings +- Try unpairing and re-pairing the devices +- Restart the ClipSync service + +#### Clipboard Not Syncing + +- Verify the ClipSync service is running (check notification) +- Ensure both devices have ClipSync installed and running +- Check that the target device is selected in your device list +- Try copying different text content + +#### App Permissions + +- Grant all requested permissions during setup +- Check Android Settings β†’ Apps β†’ ClipSync β†’ Permissions +- Ensure Bluetooth and Notification permissions are enabled + +### Still Having Issues? + +- Check our [Issues](../../issues) page for known problems +- Create a new issue with detailed information about your problem +- Include your Android version and device model + +--- + +## 🀝 Contributing + +We welcome contributions! Whether it's bug reports, feature requests, or code contributions, every bit helps make ClipSync better. + +### How to Contribute + +1. Fork the repository +2. Create a feature branch +3. Make your changes +4. Submit a pull request + +### Development Setup + +```bash +git clone https://github.com/aubynsamuel/clipsync-android.git +cd ClipSync-android +./gradlew build +``` + +--- + +## πŸ“„ License + +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. + +--- + +## πŸ™ Acknowledgments + +- Built with modern Android development practices +- Uses Jetpack Compose for beautiful UI +- Implements Material Design 3 guidelines +- Special thanks to the Android development community + +--- + +
+ Made with ❀️ for seamless device connectivity + + ⭐ Star this repo if ClipSync helps you! +
diff --git a/app/src/main/res/AppIcon.png b/app/src/main/res/AppIcon.png new file mode 100644 index 0000000..dc2ea3e Binary files /dev/null and b/app/src/main/res/AppIcon.png differ diff --git a/app/src/test/java/com/aubynsamuel/clipsync/BluetoothServiceConnectionTest.kt b/app/src/test/java/com/aubynsamuel/clipsync/BluetoothServiceConnectionTest.kt index 069b1b6..2395441 100644 --- a/app/src/test/java/com/aubynsamuel/clipsync/BluetoothServiceConnectionTest.kt +++ b/app/src/test/java/com/aubynsamuel/clipsync/BluetoothServiceConnectionTest.kt @@ -1,68 +1,68 @@ package com.aubynsamuel.clipsync -// -//import android.content.ComponentName -//import com.aubynsamuel.clipsync.bluetooth.BluetoothService -//import com.aubynsamuel.clipsync.bluetooth.BluetoothServiceConnection -//import junit.framework.TestCase -//import org.junit.Before -//import org.junit.Test -//import org.mockito.Mock -//import org.mockito.MockitoAnnotations -//import org.mockito.kotlin.mock -//import org.mockito.kotlin.whenever -// -//class BluetoothServiceConnectionTest { -// -// @Mock -// private lateinit var binder: BluetoothService.LocalBinder -// -// @Mock -// private lateinit var bluetoothService: BluetoothService -// -// private var onServiceConnectedCalled = false -// private var onServiceDisconnectedCalled = false -// private var connectedService: BluetoothService? = null -// -// private lateinit var serviceConnection: BluetoothServiceConnection -// -// @Before -// fun setup() { -// MockitoAnnotations.openMocks(this) -// -// serviceConnection = BluetoothServiceConnection( -// onServiceConnected = { service -> -// onServiceConnectedCalled = true -// connectedService = service -// }, -// onServiceDisconnected = { -// onServiceDisconnectedCalled = true -// } -// ) -// } -// -// @Test -// fun `onServiceConnected calls callback with service`() { -// // Given -// val componentName = mock() -// whenever(binder.getService()).thenReturn(bluetoothService) -// -// // When -// serviceConnection.onServiceConnected(componentName, binder) -// -// // Then -// TestCase.assertEquals(true, onServiceConnectedCalled) -// TestCase.assertEquals(bluetoothService, connectedService) -// } -// -// @Test -// fun `onServiceDisconnected calls callback`() { -// // Given -// val componentName = mock() -// -// // When -// serviceConnection.onServiceDisconnected(componentName) -// -// // Then -// TestCase.assertEquals(true, onServiceDisconnectedCalled) -// } -//} \ No newline at end of file + +import android.content.ComponentName +import com.aubynsamuel.clipsync.bluetooth.BluetoothService +import com.aubynsamuel.clipsync.bluetooth.BluetoothServiceConnection +import junit.framework.TestCase +import org.junit.Before +import org.junit.Test +import org.mockito.Mock +import org.mockito.MockitoAnnotations +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +class BluetoothServiceConnectionTest { + + @Mock + private lateinit var binder: BluetoothService.LocalBinder + + @Mock + private lateinit var bluetoothService: BluetoothService + + private var onServiceConnectedCalled = false + private var onServiceDisconnectedCalled = false + private var connectedService: BluetoothService? = null + + private lateinit var serviceConnection: BluetoothServiceConnection + + @Before + fun setup() { + MockitoAnnotations.openMocks(this) + + serviceConnection = BluetoothServiceConnection( + onServiceConnected = { service -> + onServiceConnectedCalled = true + connectedService = service + }, + onServiceDisconnected = { + onServiceDisconnectedCalled = true + } + ) + } + + @Test + fun `onServiceConnected calls callback with service`() { + // Given + val componentName = mock() + whenever(binder.getService()).thenReturn(bluetoothService) + + // When + serviceConnection.onServiceConnected(componentName, binder) + + // Then + TestCase.assertEquals(true, onServiceConnectedCalled) + TestCase.assertEquals(bluetoothService, connectedService) + } + + @Test + fun `onServiceDisconnected calls callback`() { + // Given + val componentName = mock() + + // When + serviceConnection.onServiceDisconnected(componentName) + + // Then + TestCase.assertEquals(true, onServiceDisconnectedCalled) + } +} \ No newline at end of file diff --git a/app/src/test/java/com/aubynsamuel/clipsync/BluetoothServiceTest.kt b/app/src/test/java/com/aubynsamuel/clipsync/BluetoothServiceTest.kt index 9db3ec7..a021d50 100644 --- a/app/src/test/java/com/aubynsamuel/clipsync/BluetoothServiceTest.kt +++ b/app/src/test/java/com/aubynsamuel/clipsync/BluetoothServiceTest.kt @@ -1,264 +1,245 @@ package com.aubynsamuel.clipsync -// -//import android.Manifest -//import android.bluetooth.BluetoothAdapter -//import android.bluetooth.BluetoothDevice -//import android.bluetooth.BluetoothManager -//import android.bluetooth.BluetoothServerSocket -//import android.bluetooth.BluetoothSocket -//import android.content.Context -//import android.content.Intent -//import android.os.Build -//import androidx.test.core.app.ApplicationProvider -//import com.aubynsamuel.clipsync.bluetooth.BluetoothService -//import com.aubynsamuel.clipsync.bluetooth.SharingResult -//import com.aubynsamuel.clipsync.core.Essentials -//import kotlinx.coroutines.ExperimentalCoroutinesApi -//import kotlinx.coroutines.test.runTest -//import org.json.JSONObject -//import org.junit.After -//import org.junit.Assert -//import org.junit.Before -//import org.junit.Test -//import org.junit.runner.RunWith -//import org.mockito.Mock -//import org.mockito.Mockito.timeout -//import org.mockito.Mockito.`when` -//import org.mockito.MockitoAnnotations -//import org.mockito.kotlin.any -//import org.mockito.kotlin.doReturn -//import org.mockito.kotlin.mock -//import org.mockito.kotlin.verify -//import org.robolectric.Robolectric -//import org.robolectric.RobolectricTestRunner -//import org.robolectric.android.controller.ServiceController -//import org.robolectric.annotation.Config -//import org.robolectric.shadows.ShadowApplication -//import java.io.ByteArrayInputStream -//import java.io.ByteArrayOutputStream -//import java.io.IOException -//import java.util.UUID -// -//@RunWith(RobolectricTestRunner::class) -//@Config(sdk = [Build.VERSION_CODES.S]) -//@ExperimentalCoroutinesApi -//class BluetoothServiceTest { -// -// @Mock -// private lateinit var bluetoothManager: BluetoothManager -// -// @Mock -// private lateinit var bluetoothAdapter: BluetoothAdapter -// -// @Mock -// private lateinit var serverSocket: BluetoothServerSocket -// -// @Mock -// private lateinit var clientSocket: BluetoothSocket -// -// @Mock -// private lateinit var remoteDevice: BluetoothDevice -// -// private lateinit var controller: ServiceController -// private lateinit var service: BluetoothService -// private lateinit var context: Context -// -// private val testUuid: UUID = UUID.fromString("8ce255c0-200a-11e0-ac64-0800200c9a66") -// private val testDeviceAddress = "00:11:22:33:AA:BB" -// -// @Before -// fun setUp() { -// MockitoAnnotations.openMocks(this) -// -// // Get the real application context from Robolectric -// context = ApplicationProvider.getApplicationContext() -// -// // Mock the system services -// val shadowApplication = ShadowApplication() -// shadowApplication.setSystemService(Context.BLUETOOTH_SERVICE, bluetoothManager) -// -// // Setup mocks -// `when`(bluetoothManager.adapter).thenReturn(bluetoothAdapter) -// `when`(bluetoothAdapter.listenUsingRfcommWithServiceRecord(any(), any())).thenReturn( -// serverSocket -// ) -// `when`(bluetoothAdapter.getRemoteDevice(testDeviceAddress)).thenReturn(remoteDevice) -// -// // Mock permissions - grant by default -// shadowApplication.grantPermissions(Manifest.permission.BLUETOOTH_CONNECT) -// -// // Create service controller and service -// controller = Robolectric.buildService(BluetoothService::class.java) -// service = controller.create().get() -// -// // Reset Essentials state -// Essentials.addresses = arrayOf() -// Essentials.isServiceBound = false -// } -// -// @After -// fun tearDown() { -// // Clean up -// controller.destroy() -// Essentials.addresses = arrayOf() -// Essentials.isServiceBound = false -// } -// -// @Test -// fun `onStartCommand starts bluetooth server`() { -// val intent = Intent().putExtra("SELECTED_DEVICES", arrayOf(testDeviceAddress)) -// controller.withIntent(intent).startCommand(0, 1) -// -// // Give some time for the coroutine to execute -// Thread.sleep(100) -// -// verify(bluetoothAdapter).listenUsingRfcommWithServiceRecord("ClipSync", testUuid) -// } -// -// @Test -// fun `shareClipboard returns NO_SELECTED_DEVICES when addresses are empty`() = runTest { -// // Ensure no devices are selected -// service.updateSelectedDevices(arrayOf()) -// -// val result = service.shareClipboard("test") -// Assert.assertEquals(SharingResult.NO_SELECTED_DEVICES, result) -// } -// -// @Test -// fun `shareClipboard returns PERMISSION_NOT_GRANTED when permission is missing`() = runTest { -// // Deny the permission -// val shadowApplication = ShadowApplication() -// shadowApplication.denyPermissions(Manifest.permission.BLUETOOTH_CONNECT) -// -// service.updateSelectedDevices(arrayOf(testDeviceAddress)) -// -// val result = service.shareClipboard("test clipboard") -// Assert.assertEquals(SharingResult.PERMISSION_NOT_GRANTED, result) -// } -// -// @Test -// fun `shareClipboard returns SUCCESS on successful send`() = runTest { -// val outputStream = ByteArrayOutputStream() -// `when`(remoteDevice.createRfcommSocketToServiceRecord(testUuid)).thenReturn(clientSocket) -// `when`(clientSocket.outputStream).thenReturn(outputStream) -// -// service.updateSelectedDevices(arrayOf(testDeviceAddress)) -// -// val result = service.shareClipboard("hello") -// -// Assert.assertEquals(SharingResult.SUCCESS, result) -// -// // Verify the JSON was written correctly -// val sentData = outputStream.toString().trim() -// val json = JSONObject(sentData) -// Assert.assertEquals("hello", json.getString("clip")) -// -// verify(clientSocket).connect() -// verify(clientSocket).close() -// } -// -// @Test -// fun `shareClipboard returns SENDING_ERROR on connection failure`() = runTest { -// `when`(remoteDevice.createRfcommSocketToServiceRecord(testUuid)).thenReturn(clientSocket) -// `when`(clientSocket.connect()).thenThrow(IOException("Connection failed!")) -// -// service.updateSelectedDevices(arrayOf(testDeviceAddress)) -// -// val result = service.shareClipboard("test") -// Assert.assertEquals(SharingResult.SENDING_ERROR, result) -// } -// -// @Test -// fun `server handles incoming connection and parses data`() { -// val testMessage = "{\"clip\":\"test clip data from another device\"}\n" -// val inputStream = ByteArrayInputStream(testMessage.toByteArray()) -// val incomingSocket: BluetoothSocket = mock { -// on { this.inputStream } doReturn inputStream -// } -// -// // Mock serverSocket.accept() to return the incoming socket once, then throw exception to break the loop -// `when`(serverSocket.accept()) -// .thenReturn(incomingSocket) -// .thenThrow(IOException("Server stopped")) -// -// // Start the service to initialize the server -// controller.startCommand(0, 1) -// -// // Give the background thread time to run and process the connection -// Thread.sleep(500) -// -// // Verify the socket was closed after processing -// verify(incomingSocket, timeout(1000)).close() -// } -// -// @Test -// fun `updateSelectedDevices updates addresses correctly`() = runTest { -// val testAddresses = arrayOf("11:22:33:44:55:66", "AA:BB:CC:DD:EE:FF") -// -// service.updateSelectedDevices(testAddresses) -// -// // by trying to share clipboard - it should not return NO_SELECTED_DEVICES -// // Mock the required dependencies for shareClipboard -// val mockDevice1 = mock() -// val mockDevice2 = mock() -// val mockSocket1 = mock() -// val mockSocket2 = mock() -// val outputStream1 = ByteArrayOutputStream() -// val outputStream2 = ByteArrayOutputStream() -// -// `when`(bluetoothAdapter.getRemoteDevice(testAddresses[0])).thenReturn(mockDevice1) -// `when`(bluetoothAdapter.getRemoteDevice(testAddresses[1])).thenReturn(mockDevice2) -// `when`(mockDevice1.createRfcommSocketToServiceRecord(testUuid)).thenReturn(mockSocket1) -// `when`(mockDevice2.createRfcommSocketToServiceRecord(testUuid)).thenReturn(mockSocket2) -// `when`(mockSocket1.outputStream).thenReturn(outputStream1) -// `when`(mockSocket2.outputStream).thenReturn(outputStream2) -// -// val result = service.shareClipboard("test") -// // Should not be NO_SELECTED_DEVICES since we have addresses -// assert(result != SharingResult.NO_SELECTED_DEVICES) -// } -// -// @Test -// fun `service sets isServiceBound flag in onCreate`() { -// // Reset the flag first to test the actual behavior -// Essentials.isServiceBound = false -// -// // Create a new service to trigger onCreate -// val newController = Robolectric.buildService(BluetoothService::class.java) -// newController.create() -// -// // Check if flag is set after onCreate -// Assert.assertEquals(true, Essentials.isServiceBound) -// -// // Clean up -// newController.destroy() -// } -// -// @Test -// fun `service sets bluetoothService in Essentials object in onCreate`() { -// // Reset the flag first to test the actual behavior -// Essentials.bluetoothService = null -// -// // Create a new service to trigger onCreate -// val newController = Robolectric.buildService(BluetoothService::class.java) -// val newService = newController.create().get() -// -// // Check if flag is set after onCreate -// Assert.assertEquals(newService, Essentials.bluetoothService) -// -// // Clean up -// newController.destroy() -// } -// -// @Test -// fun `service clears isServiceBound flag in onDestroy`() { -// controller.destroy() -// Assert.assertEquals(null, Essentials.isServiceBound) -// } -// -// @Test -// fun `service clears bluetoothService in Essentials object in onDestroy`() { -// controller.destroy() -// Assert.assertEquals(null, Essentials.bluetoothService) -// } -//} \ No newline at end of file + +import android.Manifest +import android.app.NotificationManager +import android.bluetooth.BluetoothAdapter +import android.bluetooth.BluetoothDevice +import android.bluetooth.BluetoothManager +import android.bluetooth.BluetoothServerSocket +import android.bluetooth.BluetoothSocket +import android.content.ClipboardManager +import android.content.Context +import android.content.Intent +import android.os.Build +import androidx.test.core.app.ApplicationProvider +import com.aubynsamuel.clipsync.bluetooth.BluetoothService +import com.aubynsamuel.clipsync.bluetooth.SharingResult +import com.aubynsamuel.clipsync.core.Essentials +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.json.JSONObject +import org.junit.After +import org.junit.Assert +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.Mockito.timeout +import org.mockito.Mockito.`when` +import org.mockito.MockitoAnnotations +import org.mockito.kotlin.any +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.android.controller.ServiceController +import org.robolectric.annotation.Config +import org.robolectric.shadows.ShadowApplication +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.IOException +import java.util.UUID + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [Build.VERSION_CODES.S]) +@ExperimentalCoroutinesApi +class BluetoothServiceTest { + + @Mock + private lateinit var bluetoothManager: BluetoothManager + + @Mock + private lateinit var bluetoothAdapter: BluetoothAdapter + + @Mock + private lateinit var serverSocket: BluetoothServerSocket + + @Mock + private lateinit var clientSocket: BluetoothSocket + + @Mock + private lateinit var remoteDevice: BluetoothDevice + + @Mock + private lateinit var notificationManager: NotificationManager + + @Mock + private lateinit var clipboardManager: ClipboardManager + + private lateinit var controller: ServiceController + private lateinit var service: BluetoothService + private lateinit var context: Context + + private val testUuid: UUID = UUID.fromString("8ce255c0-200a-11e0-ac64-0800200c9a66") + private val testDeviceAddress = "00:11:22:33:AA:BB" + + @Before + fun setUp() { + MockitoAnnotations.openMocks(this) + + context = ApplicationProvider.getApplicationContext() + + val shadowApplication = shadowOf(context.applicationContext) + shadowApplication.setSystemService(Context.BLUETOOTH_SERVICE, bluetoothManager) + shadowApplication.setSystemService(Context.NOTIFICATION_SERVICE, notificationManager) + shadowApplication.setSystemService(Context.CLIPBOARD_SERVICE, clipboardManager) + + `when`(bluetoothManager.adapter).thenReturn(bluetoothAdapter) + `when`(bluetoothAdapter.listenUsingRfcommWithServiceRecord(any(), any())).thenReturn( + serverSocket + ) + `when`(bluetoothAdapter.getRemoteDevice(testDeviceAddress)).thenReturn(remoteDevice) + + shadowApplication.grantPermissions(Manifest.permission.BLUETOOTH_CONNECT) + + controller = Robolectric.buildService(BluetoothService::class.java) + service = controller.create().get() + + Essentials.addresses = arrayOf() + Essentials.isServiceBound = false + } + + @After + fun tearDown() { + controller.destroy() + Essentials.addresses = arrayOf() + Essentials.isServiceBound = false + } + + @Test + fun `onStartCommand starts bluetooth server`() = runTest { + val intent = Intent().putExtra("SELECTED_DEVICES", arrayOf(testDeviceAddress)) + controller.withIntent(intent).startCommand(0, 1) + + verify(bluetoothAdapter).listenUsingRfcommWithServiceRecord("ClipSync", testUuid) + } + + @Test + fun `shareClipboard returns NO_SELECTED_DEVICES when addresses are empty`() = runTest { + service.updateSelectedDevices(arrayOf()) + + val result = service.shareClipboard("test") + Assert.assertEquals(SharingResult.NO_SELECTED_DEVICES, result) + } + + @Test + fun `shareClipboard returns PERMISSION_NOT_GRANTED when permission is missing`() = runTest { + val shadowApplication = shadowOf(context.applicationContext) + shadowApplication.denyPermissions(Manifest.permission.BLUETOOTH_CONNECT) + + service.updateSelectedDevices(arrayOf(testDeviceAddress)) + + val result = service.shareClipboard("test clipboard") + Assert.assertEquals(SharingResult.PERMISSION_NOT_GRANTED, result) + } + + @Test + fun `shareClipboard returns SUCCESS on successful send`() = runTest { + val outputStream = ByteArrayOutputStream() + `when`(remoteDevice.createRfcommSocketToServiceRecord(testUuid)).thenReturn(clientSocket) + `when`(clientSocket.outputStream).thenReturn(outputStream) + + service.updateSelectedDevices(arrayOf(testDeviceAddress)) + + val result = service.shareClipboard("hello") + + Assert.assertEquals(SharingResult.SUCCESS, result) + + val sentData = outputStream.toString().trim() + val json = JSONObject(sentData) + Assert.assertEquals("hello", json.getString("clip")) + + verify(clientSocket).connect() + verify(clientSocket).close() + } + + @Test + fun `shareClipboard returns SENDING_ERROR on connection failure`() = runTest { + `when`(remoteDevice.createRfcommSocketToServiceRecord(testUuid)).thenReturn(clientSocket) + `when`(clientSocket.connect()).thenThrow(IOException("Connection failed!")) + + service.updateSelectedDevices(arrayOf(testDeviceAddress)) + + val result = service.shareClipboard("test") + Assert.assertEquals(SharingResult.SENDING_ERROR, result) + } + + @Test + fun `server handles incoming connection and parses data`() = runTest { + val testMessage = "{\"clip\":\"test clip data from another device\"}\n" + val inputStream = ByteArrayInputStream(testMessage.toByteArray()) + val incomingSocket: BluetoothSocket = mock { + on { this.inputStream } doReturn inputStream + } + + `when`(serverSocket.accept()) + .thenReturn(incomingSocket) + .thenThrow(IOException("Server stopped")) + + controller.startCommand(0, 1) + + verify(incomingSocket, timeout(1000)).close() + } + + @Test + fun `updateSelectedDevices updates addresses correctly`() = runTest { + val testAddresses = arrayOf("11:22:33:44:55:66", "AA:BB:CC:DD:EE:FF") + + service.updateSelectedDevices(testAddresses) + + val mockDevice1 = mock() + val mockDevice2 = mock() + val mockSocket1 = mock() + val mockSocket2 = mock() + val outputStream1 = ByteArrayOutputStream() + val outputStream2 = ByteArrayOutputStream() + + `when`(bluetoothAdapter.getRemoteDevice(testAddresses[0])).thenReturn(mockDevice1) + `when`(bluetoothAdapter.getRemoteDevice(testAddresses[1])).thenReturn(mockDevice2) + `when`(mockDevice1.createRfcommSocketToServiceRecord(testUuid)).thenReturn(mockSocket1) + `when`(mockDevice2.createRfcommSocketToServiceRecord(testUuid)).thenReturn(mockSocket2) + `when`(mockSocket1.outputStream).thenReturn(outputStream1) + `when`(mockSocket2.outputStream).thenReturn(outputStream2) + + val result = service.shareClipboard("test") + assert(result != SharingResult.NO_SELECTED_DEVICES) + } + + @Test + fun `service sets isServiceBound flag in onCreate`() { + Essentials.isServiceBound = false + + val newController = Robolectric.buildService(BluetoothService::class.java) + newController.create() + + Assert.assertEquals(true, Essentials.isServiceBound) + + newController.destroy() + } + + @Test + fun `service sets bluetoothService in Essentials object in onCreate`() { + Essentials.bluetoothService = null + + val newController = Robolectric.buildService(BluetoothService::class.java) + val newService = newController.create().get() + + Assert.assertEquals(newService, Essentials.bluetoothService) + + newController.destroy() + } + + @Test + fun `service clears isServiceBound flag in onDestroy`() { + controller.destroy() + Assert.assertEquals(false, Essentials.isServiceBound) + } + + @Test + fun `service clears bluetoothService in Essentials object in onDestroy`() { + controller.destroy() + Assert.assertEquals(null, Essentials.bluetoothService) + } +} \ No newline at end of file diff --git a/app/src/test/java/com/aubynsamuel/clipsync/MainActivityTest.kt b/app/src/test/java/com/aubynsamuel/clipsync/MainActivityTest.kt index 0507630..5a0d0b6 100644 --- a/app/src/test/java/com/aubynsamuel/clipsync/MainActivityTest.kt +++ b/app/src/test/java/com/aubynsamuel/clipsync/MainActivityTest.kt @@ -1,376 +1,376 @@ package com.aubynsamuel.clipsync -// -//import android.Manifest -//import android.bluetooth.BluetoothAdapter -//import android.bluetooth.BluetoothDevice -//import android.bluetooth.BluetoothManager -//import android.content.Context -//import android.os.Build -//import androidx.test.core.app.ApplicationProvider -//import com.aubynsamuel.clipsync.activities.MainActivity -//import com.aubynsamuel.clipsync.bluetooth.BluetoothService -//import kotlinx.coroutines.ExperimentalCoroutinesApi -//import kotlinx.coroutines.test.runTest -//import org.junit.Assert.assertEquals -//import org.junit.Assert.assertTrue -//import org.junit.Before -//import org.junit.Test -//import org.junit.runner.RunWith -//import org.mockito.Mock -//import org.mockito.Mockito.never -//import org.mockito.Mockito.verify -//import org.mockito.Mockito.`when` -//import org.mockito.MockitoAnnotations -//import org.robolectric.Robolectric -//import org.robolectric.RobolectricTestRunner -//import org.robolectric.android.controller.ActivityController -//import org.robolectric.annotation.Config -//import org.robolectric.shadows.ShadowApplication -//import org.robolectric.shadows.ShadowToast -//import org.robolectric.util.ReflectionHelpers -// -//@RunWith(RobolectricTestRunner::class) -//@Config(sdk = [Build.VERSION_CODES.S]) -//@ExperimentalCoroutinesApi -//class MainActivityTest { -// -// @Mock -// private lateinit var mockBluetoothManager: BluetoothManager -// -// @Mock -// private lateinit var mockBluetoothAdapter: BluetoothAdapter -// -// @Mock -// private lateinit var mockBluetoothDevice1: BluetoothDevice -// -// @Mock -// private lateinit var mockBluetoothDevice2: BluetoothDevice -// -// private lateinit var controller: ActivityController -// private lateinit var activity: MainActivity -// private lateinit var context: Context -// private lateinit var shadowApplication: ShadowApplication -// -// @Before -// fun setUp() { -// MockitoAnnotations.openMocks(this) -// -// // Get the real application context from Robolectric -// context = ApplicationProvider.getApplicationContext() -// shadowApplication = ShadowApplication() -// -// // Mock system services -// shadowApplication.setSystemService(Context.BLUETOOTH_SERVICE, mockBluetoothManager) -// -// // Setup mock bluetooth manager to return mock adapter -// `when`(mockBluetoothManager.adapter).thenReturn(mockBluetoothAdapter) -// -// // Grant permissions by default -// shadowApplication.grantPermissions( -// Manifest.permission.BLUETOOTH_CONNECT, -// Manifest.permission.BLUETOOTH_SCAN, -// Manifest.permission.BLUETOOTH_ADVERTISE, -// Manifest.permission.POST_NOTIFICATIONS, -// Manifest.permission.BLUETOOTH, -// Manifest.permission.BLUETOOTH_ADMIN -// ) -// -// // Create activity controller and activity -// controller = Robolectric.buildActivity(MainActivity::class.java) -// activity = controller.create().resume().get() // Resume to trigger onCreate -// -// // Clear any previous toasts -// ShadowToast.reset() -// } -// -// @Test -// fun `onCreate should initialize bluetooth components`() { -// // Given - activity is already created and resumed in setUp -// -// // Then - verify activity was created successfully -// assertTrue(true) -// } -// -// @Test -// fun `checkPermissions should request missing permissions on Android S and above`() { -// // Given - deny one permission -// shadowApplication.denyPermissions(Manifest.permission.BLUETOOTH_CONNECT) -// -// // When + +import android.Manifest +import android.bluetooth.BluetoothAdapter +import android.bluetooth.BluetoothDevice +import android.bluetooth.BluetoothManager +import android.content.Context +import android.os.Build +import androidx.test.core.app.ApplicationProvider +import com.aubynsamuel.clipsync.activities.MainActivity +import com.aubynsamuel.clipsync.bluetooth.BluetoothService +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.Mockito.never +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` +import org.mockito.MockitoAnnotations +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.android.controller.ActivityController +import org.robolectric.annotation.Config +import org.robolectric.shadows.ShadowApplication +import org.robolectric.shadows.ShadowToast +import org.robolectric.util.ReflectionHelpers + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [Build.VERSION_CODES.S]) +@ExperimentalCoroutinesApi +class MainActivityTest { + + @Mock + private lateinit var mockBluetoothManager: BluetoothManager + + @Mock + private lateinit var mockBluetoothAdapter: BluetoothAdapter + + @Mock + private lateinit var mockBluetoothDevice1: BluetoothDevice + + @Mock + private lateinit var mockBluetoothDevice2: BluetoothDevice + + private lateinit var controller: ActivityController + private lateinit var activity: MainActivity + private lateinit var context: Context + private lateinit var shadowApplication: ShadowApplication + + @Before + fun setUp() { + MockitoAnnotations.openMocks(this) + + // Get the real application context from Robolectric + context = ApplicationProvider.getApplicationContext() + shadowApplication = ShadowApplication() + + // Mock system services + shadowApplication.setSystemService(Context.BLUETOOTH_SERVICE, mockBluetoothManager) + + // Setup mock bluetooth manager to return mock adapter + `when`(mockBluetoothManager.adapter).thenReturn(mockBluetoothAdapter) + + // Grant permissions by default + shadowApplication.grantPermissions( + Manifest.permission.BLUETOOTH_CONNECT, + Manifest.permission.BLUETOOTH_SCAN, + Manifest.permission.BLUETOOTH_ADVERTISE, + Manifest.permission.POST_NOTIFICATIONS, + Manifest.permission.BLUETOOTH, + Manifest.permission.BLUETOOTH_ADMIN + ) + + // Create activity controller and activity + controller = Robolectric.buildActivity(MainActivity::class.java) + activity = controller.create().resume().get() // Resume to trigger onCreate + + // Clear any previous toasts + ShadowToast.reset() + } + + @Test + fun `onCreate should initialize bluetooth components`() { + // Given - activity is already created and resumed in setUp + + // Then - verify activity was created successfully + assertTrue(true) + } + + @Test + fun `checkPermissions should request missing permissions on Android S and above`() { + // Given - deny one permission + shadowApplication.denyPermissions(Manifest.permission.BLUETOOTH_CONNECT) + + // When + ReflectionHelpers.callInstanceMethod(activity, "checkPermissions") + + // Then - verify permission request flow was triggered + // In a real scenario, this would trigger the permission launcher + assertTrue("Activity should handle missing permissions", true) + } + + @Test + fun `checkPermissions should proceed to bluetooth check when all permissions granted`() { + // Given - all permissions already granted in setUp + `when`(mockBluetoothAdapter.isEnabled).thenReturn(true) + `when`(mockBluetoothAdapter.bondedDevices).thenReturn(emptySet()) + + /** + * No need to call checkPermissions explicitly, it is called in onCreate + * which is called in setUp + */ // ReflectionHelpers.callInstanceMethod(activity, "checkPermissions") -// -// // Then - verify permission request flow was triggered -// // In a real scenario, this would trigger the permission launcher -// assertTrue("Activity should handle missing permissions", true) -// } -// -// @Test -// fun `checkPermissions should proceed to bluetooth check when all permissions granted`() { -// // Given - all permissions already granted in setUp -// `when`(mockBluetoothAdapter.isEnabled).thenReturn(true) -// `when`(mockBluetoothAdapter.bondedDevices).thenReturn(emptySet()) -// -// /** -// * No need to call checkPermissions explicitly, it is called in onCreate -// * which is called in setUp -// */ -//// ReflectionHelpers.callInstanceMethod(activity, "checkPermissions") -// -// verify(mockBluetoothAdapter).isEnabled -// } -// -// @Test -// fun `checkBluetoothEnabled should request enable when bluetooth disabled`() { -// // Given -// `when`(mockBluetoothAdapter.isEnabled).thenReturn(false) -// -// // When -// ReflectionHelpers.callInstanceMethod(activity, "checkBluetoothEnabled") -// -// // Then - verify enable bluetooth intent was started -// val nextStartedActivity = shadowApplication.nextStartedActivity -// assertEquals(BluetoothAdapter.ACTION_REQUEST_ENABLE, nextStartedActivity.action) -// } -// -// @Test -// fun `checkBluetoothEnabled should load paired devices when bluetooth enabled`() { -// // Given -// `when`(mockBluetoothAdapter.isEnabled).thenReturn(true) -// val mockDevices = setOf(mockBluetoothDevice1, mockBluetoothDevice2) -// `when`(mockBluetoothAdapter.bondedDevices).thenReturn(mockDevices) -// -// // When -// ReflectionHelpers.callInstanceMethod(activity, "checkBluetoothEnabled") -// -// // Then -// verify(mockBluetoothAdapter).bondedDevices -// } -// -// @Test -// fun `loadPairedDevices should return empty set when permission denied`() { -// // Given - deny bluetooth permission -// shadowApplication.denyPermissions(Manifest.permission.BLUETOOTH_CONNECT) -// -// // When -// val result = ReflectionHelpers.callInstanceMethod>( -// activity, "loadPairedDevices" -// ) -// -// // Then -// assertTrue(result.isEmpty()) -// verify(mockBluetoothAdapter, never()).bondedDevices -// } -// -// @Test -// fun `loadPairedDevices should return bonded devices when permission granted`() { -// // Given -// val mockDevices = setOf(mockBluetoothDevice1, mockBluetoothDevice2) -// `when`(mockBluetoothAdapter.bondedDevices).thenReturn(mockDevices) -// -// // When -// val result = ReflectionHelpers.callInstanceMethod>( -// activity, "loadPairedDevices" -// ) -// -// // Then -// assertEquals(mockDevices, result) -// verify(mockBluetoothAdapter).bondedDevices -// } -// -// @Test -// fun `loadPairedDevices should handle null bonded devices`() { -// // Given -// `when`(mockBluetoothAdapter.bondedDevices).thenReturn(null) -// -// // When -// val result = ReflectionHelpers.callInstanceMethod>( -// activity, "loadPairedDevices" -// ) -// -// // Then -// assertTrue(result.isEmpty()) -// } -// -// @Test -// @Config(sdk = [Build.VERSION_CODES.O]) -// fun `startBluetoothService should start foreground service on Android O and above`() { -// // Given -// val selectedDevices = setOf("device1", "device2") -// -// // When -// ReflectionHelpers.callInstanceMethod( -// activity, "startBluetoothService", -// ReflectionHelpers.ClassParameter(Set::class.java, selectedDevices) -// ) -// -// // Then -// val nextStartedService = shadowApplication.nextStartedService -// assertEquals(BluetoothService::class.java.name, nextStartedService.component?.className) -// val extras = nextStartedService.getStringArrayExtra("SELECTED_DEVICES") -// assertTrue(extras?.toSet() == selectedDevices) -// } -// -// @Test -// @Config(sdk = [Build.VERSION_CODES.N]) -// fun `startBluetoothService should start regular service on pre-Android O`() { -// // Given -// val selectedDevices = setOf("device1", "device2") -// -// // When -// ReflectionHelpers.callInstanceMethod( -// activity, "startBluetoothService", -// ReflectionHelpers.ClassParameter(Set::class.java, selectedDevices) -// ) -// -// // Then -// val nextStartedService = shadowApplication.nextStartedService -// assertEquals(BluetoothService::class.java.name, nextStartedService.component?.className) -// } -// -// @Test -// fun `stopBluetoothService should stop the bluetooth service`() { -// // When -// ReflectionHelpers.callInstanceMethod(activity, "stopBluetoothService") -// -// // Then -// val nextStoppedService = shadowApplication.nextStoppedService -// assertEquals(BluetoothService::class.java.name, nextStoppedService.component?.className) -// } -// -// @Test -// fun `requestEnableBluetooth callback should load paired devices on success`() = runTest { -// // Given -// `when`(mockBluetoothAdapter.bondedDevices).thenReturn(setOf(mockBluetoothDevice1)) -// `when`(mockBluetoothAdapter.isEnabled).thenReturn(true) -// -// // When - simulate the callback flow by directly calling checkBluetoothEnabled after enabling -// ReflectionHelpers.callInstanceMethod(activity, "checkBluetoothEnabled") -// -// // Then - verify paired devices were loaded -// verify(mockBluetoothAdapter).bondedDevices -// } -// -// @Test -// fun `requestEnableBluetooth callback should show toast and retry on failure`() { -// // Given -// `when`(mockBluetoothAdapter.isEnabled).thenReturn(false) -// -// // When - simulate failure by checking bluetooth when disabled -// ReflectionHelpers.callInstanceMethod(activity, "checkBluetoothEnabled") -// -// // Then - verify enable bluetooth request was made (retry behavior) -// val nextStartedActivity = shadowApplication.nextStartedActivity -// assertEquals(BluetoothAdapter.ACTION_REQUEST_ENABLE, nextStartedActivity.action) -// } -// -// @Test -// fun `permissions callback should proceed when all permissions granted`() = runTest { -// // Given - all permissions granted (already set in setUp) -// `when`(mockBluetoothAdapter.isEnabled).thenReturn(true) -// `when`(mockBluetoothAdapter.bondedDevices).thenReturn(setOf(mockBluetoothDevice1)) -// -// // When -// ReflectionHelpers.callInstanceMethod(activity, "checkPermissions") -// -// // Then - verify that `checkBluetoothEnabled` which loads devices is called. -// verify(mockBluetoothAdapter).bondedDevices -// } -// -// @Test -// fun `permissions callback should handle denied permissions gracefully`() { -// // Given - deny a critical permission -// shadowApplication.denyPermissions(Manifest.permission.BLUETOOTH_CONNECT) -// -// // When -// ReflectionHelpers.callInstanceMethod(activity, "checkPermissions") -// -// // Then - verify graceful handling (no crash, appropriate flow) -// assertTrue("Activity should handle denied permissions gracefully", true) -// } -// -// @Test -// @Config(sdk = [Build.VERSION_CODES.S]) -// fun `activity should request correct permissions for Android S and above`() { -// // Given - deny all Android S permissions -// shadowApplication.denyPermissions( -// Manifest.permission.BLUETOOTH_CONNECT, -// Manifest.permission.BLUETOOTH_SCAN, -// Manifest.permission.BLUETOOTH_ADVERTISE, -// Manifest.permission.POST_NOTIFICATIONS -// ) -// -// // When -// ReflectionHelpers.callInstanceMethod(activity, "checkPermissions") -// -// // Then - verify the method doesn't crash and handles S+ permissions -// assertTrue("Should handle Android S+ permissions correctly", true) -// } -// -// @Test -// @Config(sdk = [Build.VERSION_CODES.R]) -// fun `activity should request correct permissions for pre-Android S`() { -// // Given - deny legacy permissions -// shadowApplication.denyPermissions( -// Manifest.permission.BLUETOOTH, -// Manifest.permission.BLUETOOTH_ADMIN -// ) -// -// // When -// ReflectionHelpers.callInstanceMethod(activity, "checkPermissions") -// -// // Then - verify the method handles legacy permissions -// assertTrue("Should handle pre-Android S permissions correctly", true) -// } -// -// @Test -// fun `startBluetoothService should pass correct device addresses`() { -// // Given -// val testDevices = setOf("AA:BB:CC:DD:EE:FF", "11:22:33:44:55:66") -// -// // When -// ReflectionHelpers.callInstanceMethod( -// activity, "startBluetoothService", -// ReflectionHelpers.ClassParameter(Set::class.java, testDevices) -// ) -// -// // Then -// val startedService = shadowApplication.nextStartedService -// val deviceArray = startedService.getStringArrayExtra("SELECTED_DEVICES") -// assertEquals(testDevices, deviceArray?.toSet()) -// } -// -// @Test -// fun `loadPairedDevices should update pairedDevices state`() { -// // Given -// val testDevices = setOf(mockBluetoothDevice1, mockBluetoothDevice2) -// `when`(mockBluetoothAdapter.bondedDevices).thenReturn(testDevices) -// -// // When -// ReflectionHelpers.callInstanceMethod>( -// activity, "loadPairedDevices" -// ) -// -// // Then -// // We can't directly access and assert the MutableState. -// // Instead, we verify that the method that *uses* this state -// // (or the method that updates it) was called as expected. -// // For this test, verifying that `getBondedDevices` was called -// // implies that the state update logic within `loadPairedDevices` ran. -// verify(mockBluetoothAdapter).bondedDevices -// // Further testing of the UI or other methods that observe this state -// // would be needed for complete verification of the state update. -// } -// -// @Test -// fun `activity should handle bluetooth adapter initialization`() { -// // Given - create a new activity to test initialization -// val newController = Robolectric.buildActivity(MainActivity::class.java) -// val newActivity = newController.create().get() -// -// // Then - verify activity initialized without crashing -// assertTrue(newActivity is MainActivity) -// -// // Clean up -// newController.destroy() -// } -//} \ No newline at end of file + + verify(mockBluetoothAdapter).isEnabled + } + + @Test + fun `checkBluetoothEnabled should request enable when bluetooth disabled`() { + // Given + `when`(mockBluetoothAdapter.isEnabled).thenReturn(false) + + // When + ReflectionHelpers.callInstanceMethod(activity, "checkBluetoothEnabled") + + // Then - verify enable bluetooth intent was started + val nextStartedActivity = shadowApplication.nextStartedActivity + assertEquals(BluetoothAdapter.ACTION_REQUEST_ENABLE, nextStartedActivity.action) + } + + @Test + fun `checkBluetoothEnabled should load paired devices when bluetooth enabled`() { + // Given + `when`(mockBluetoothAdapter.isEnabled).thenReturn(true) + val mockDevices = setOf(mockBluetoothDevice1, mockBluetoothDevice2) + `when`(mockBluetoothAdapter.bondedDevices).thenReturn(mockDevices) + + // When + ReflectionHelpers.callInstanceMethod(activity, "checkBluetoothEnabled") + + // Then + verify(mockBluetoothAdapter).bondedDevices + } + + @Test + fun `loadPairedDevices should return empty set when permission denied`() { + // Given - deny bluetooth permission + shadowApplication.denyPermissions(Manifest.permission.BLUETOOTH_CONNECT) + + // When + val result = ReflectionHelpers.callInstanceMethod>( + activity, "loadPairedDevices" + ) + + // Then + assertTrue(result.isEmpty()) + verify(mockBluetoothAdapter, never()).bondedDevices + } + + @Test + fun `loadPairedDevices should return bonded devices when permission granted`() { + // Given + val mockDevices = setOf(mockBluetoothDevice1, mockBluetoothDevice2) + `when`(mockBluetoothAdapter.bondedDevices).thenReturn(mockDevices) + + // When + val result = ReflectionHelpers.callInstanceMethod>( + activity, "loadPairedDevices" + ) + + // Then + assertEquals(mockDevices, result) + verify(mockBluetoothAdapter).bondedDevices + } + + @Test + fun `loadPairedDevices should handle null bonded devices`() { + // Given + `when`(mockBluetoothAdapter.bondedDevices).thenReturn(null) + + // When + val result = ReflectionHelpers.callInstanceMethod>( + activity, "loadPairedDevices" + ) + + // Then + assertTrue(result.isEmpty()) + } + + @Test + @Config(sdk = [Build.VERSION_CODES.O]) + fun `startBluetoothService should start foreground service on Android O and above`() { + // Given + val selectedDevices = setOf("device1", "device2") + + // When + ReflectionHelpers.callInstanceMethod( + activity, "startBluetoothService", + ReflectionHelpers.ClassParameter(Set::class.java, selectedDevices) + ) + + // Then + val nextStartedService = shadowApplication.nextStartedService + assertEquals(BluetoothService::class.java.name, nextStartedService.component?.className) + val extras = nextStartedService.getStringArrayExtra("SELECTED_DEVICES") + assertTrue(extras?.toSet() == selectedDevices) + } + + @Test + @Config(sdk = [Build.VERSION_CODES.N]) + fun `startBluetoothService should start regular service on pre-Android O`() { + // Given + val selectedDevices = setOf("device1", "device2") + + // When + ReflectionHelpers.callInstanceMethod( + activity, "startBluetoothService", + ReflectionHelpers.ClassParameter(Set::class.java, selectedDevices) + ) + + // Then + val nextStartedService = shadowApplication.nextStartedService + assertEquals(BluetoothService::class.java.name, nextStartedService.component?.className) + } + + @Test + fun `stopBluetoothService should stop the bluetooth service`() { + // When + ReflectionHelpers.callInstanceMethod(activity, "stopBluetoothService") + + // Then + val nextStoppedService = shadowApplication.nextStoppedService + assertEquals(BluetoothService::class.java.name, nextStoppedService.component?.className) + } + + @Test + fun `requestEnableBluetooth callback should load paired devices on success`() = runTest { + // Given + `when`(mockBluetoothAdapter.bondedDevices).thenReturn(setOf(mockBluetoothDevice1)) + `when`(mockBluetoothAdapter.isEnabled).thenReturn(true) + + // When - simulate the callback flow by directly calling checkBluetoothEnabled after enabling + ReflectionHelpers.callInstanceMethod(activity, "checkBluetoothEnabled") + + // Then - verify paired devices were loaded + verify(mockBluetoothAdapter).bondedDevices + } + + @Test + fun `requestEnableBluetooth callback should show toast and retry on failure`() { + // Given + `when`(mockBluetoothAdapter.isEnabled).thenReturn(false) + + // When - simulate failure by checking bluetooth when disabled + ReflectionHelpers.callInstanceMethod(activity, "checkBluetoothEnabled") + + // Then - verify enable bluetooth request was made (retry behavior) + val nextStartedActivity = shadowApplication.nextStartedActivity + assertEquals(BluetoothAdapter.ACTION_REQUEST_ENABLE, nextStartedActivity.action) + } + + @Test + fun `permissions callback should proceed when all permissions granted`() = runTest { + // Given - all permissions granted (already set in setUp) + `when`(mockBluetoothAdapter.isEnabled).thenReturn(true) + `when`(mockBluetoothAdapter.bondedDevices).thenReturn(setOf(mockBluetoothDevice1)) + + // When + ReflectionHelpers.callInstanceMethod(activity, "checkPermissions") + + // Then - verify that `checkBluetoothEnabled` which loads devices is called. + verify(mockBluetoothAdapter).bondedDevices + } + + @Test + fun `permissions callback should handle denied permissions gracefully`() { + // Given - deny a critical permission + shadowApplication.denyPermissions(Manifest.permission.BLUETOOTH_CONNECT) + + // When + ReflectionHelpers.callInstanceMethod(activity, "checkPermissions") + + // Then - verify graceful handling (no crash, appropriate flow) + assertTrue("Activity should handle denied permissions gracefully", true) + } + + @Test + @Config(sdk = [Build.VERSION_CODES.S]) + fun `activity should request correct permissions for Android S and above`() { + // Given - deny all Android S permissions + shadowApplication.denyPermissions( + Manifest.permission.BLUETOOTH_CONNECT, + Manifest.permission.BLUETOOTH_SCAN, + Manifest.permission.BLUETOOTH_ADVERTISE, + Manifest.permission.POST_NOTIFICATIONS + ) + + // When + ReflectionHelpers.callInstanceMethod(activity, "checkPermissions") + + // Then - verify the method doesn't crash and handles S+ permissions + assertTrue("Should handle Android S+ permissions correctly", true) + } + + @Test + @Config(sdk = [Build.VERSION_CODES.R]) + fun `activity should request correct permissions for pre-Android S`() { + // Given - deny legacy permissions + shadowApplication.denyPermissions( + Manifest.permission.BLUETOOTH, + Manifest.permission.BLUETOOTH_ADMIN + ) + + // When + ReflectionHelpers.callInstanceMethod(activity, "checkPermissions") + + // Then - verify the method handles legacy permissions + assertTrue("Should handle pre-Android S permissions correctly", true) + } + + @Test + fun `startBluetoothService should pass correct device addresses`() { + // Given + val testDevices = setOf("AA:BB:CC:DD:EE:FF", "11:22:33:44:55:66") + + // When + ReflectionHelpers.callInstanceMethod( + activity, "startBluetoothService", + ReflectionHelpers.ClassParameter(Set::class.java, testDevices) + ) + + // Then + val startedService = shadowApplication.nextStartedService + val deviceArray = startedService.getStringArrayExtra("SELECTED_DEVICES") + assertEquals(testDevices, deviceArray?.toSet()) + } + + @Test + fun `loadPairedDevices should update pairedDevices state`() { + // Given + val testDevices = setOf(mockBluetoothDevice1, mockBluetoothDevice2) + `when`(mockBluetoothAdapter.bondedDevices).thenReturn(testDevices) + + // When + ReflectionHelpers.callInstanceMethod>( + activity, "loadPairedDevices" + ) + + // Then + // We can't directly access and assert the MutableState. + // Instead, we verify that the method that *uses* this state + // (or the method that updates it) was called as expected. + // For this test, verifying that `getBondedDevices` was called + // implies that the state update logic within `loadPairedDevices` ran. + verify(mockBluetoothAdapter).bondedDevices + // Further testing of the UI or other methods that observe this state + // would be needed for complete verification of the state update. + } + + @Test + fun `activity should handle bluetooth adapter initialization`() { + // Given - create a new activity to test initialization + val newController = Robolectric.buildActivity(MainActivity::class.java) + val newActivity = newController.create().get() + + // Then - verify activity initialized without crashing + assertTrue(newActivity is MainActivity) + + // Clean up + newController.destroy() + } +} \ No newline at end of file diff --git a/app/src/test/java/com/aubynsamuel/clipsync/NotificationReceiverTest.kt b/app/src/test/java/com/aubynsamuel/clipsync/NotificationReceiverTest.kt index 934d148..6e974c4 100644 --- a/app/src/test/java/com/aubynsamuel/clipsync/NotificationReceiverTest.kt +++ b/app/src/test/java/com/aubynsamuel/clipsync/NotificationReceiverTest.kt @@ -1,284 +1,284 @@ package com.aubynsamuel.clipsync -// -//import android.app.NotificationManager -//import android.content.ClipData -//import android.content.ClipboardManager -//import android.content.Context -//import android.content.Intent -//import android.os.Build -//import androidx.test.core.app.ApplicationProvider -//import com.aubynsamuel.clipsync.bluetooth.BluetoothService -//import com.aubynsamuel.clipsync.notification.NotificationReceiver -//import org.junit.Assert.assertEquals -//import org.junit.Assert.assertNull -//import org.junit.Before -//import org.junit.Test -//import org.junit.runner.RunWith -//import org.mockito.ArgumentCaptor -//import org.mockito.Mock -//import org.mockito.Mockito.never -//import org.mockito.Mockito.times -//import org.mockito.Mockito.verify -//import org.mockito.MockitoAnnotations -//import org.mockito.kotlin.any -//import org.robolectric.RobolectricTestRunner -//import org.robolectric.annotation.Config -//import org.robolectric.shadows.ShadowApplication -//import org.robolectric.shadows.ShadowToast -// -//@RunWith(RobolectricTestRunner::class) -//@Config(sdk = [Build.VERSION_CODES.S]) // Testing with Android 12 (API 31) -//class NotificationReceiverTest { -// -// private lateinit var notificationReceiver: NotificationReceiver -// private lateinit var context: Context -// private lateinit var shadowApplication: ShadowApplication -// -// @Mock -// private lateinit var mockNotificationManager: NotificationManager -// -// @Mock -// private lateinit var mockClipboardManager: ClipboardManager -// -// @Before -// fun setUp() { -// MockitoAnnotations.openMocks(this) -// -// notificationReceiver = NotificationReceiver() -// context = ApplicationProvider.getApplicationContext() -// shadowApplication = ShadowApplication() -// -// // Mock system services -// shadowApplication.setSystemService(Context.NOTIFICATION_SERVICE, mockNotificationManager) -// shadowApplication.setSystemService(Context.CLIPBOARD_SERVICE, mockClipboardManager) -// } -// -// @Test -// fun `onReceive with ACTION_DISMISS stops service and cancels notification`() { -// // Arrange -// val intent = Intent().apply { -// action = "ACTION_DISMISS" -// } -// -// // Act -// notificationReceiver.onReceive(context, intent) -// -// // Assert -// // Verify service was stopped -// val nextStoppedService = shadowApplication.nextStoppedService -// assertEquals(BluetoothService::class.java.name, nextStoppedService.component?.className) -// -// // Verify notification was cancelled -// verify(mockNotificationManager).cancel(1001) -// } -// -// @Test -// fun `onReceive with ACTION_COPY copies text to clipboard and shows toast on older Android`() { -// // Arrange -// val testClipText = "Test clipboard text" -// val testNotificationId = 12345 -// val intent = Intent().apply { -// action = "ACTION_COPY" -// putExtra("CLIP_TEXT", testClipText) -// putExtra("NOTIFICATION_ID", testNotificationId) -// } -// -// val clipDataCaptor = ArgumentCaptor.forClass(ClipData::class.java) -// -// // Act -// notificationReceiver.onReceive(context, intent) -// -// // Assert -// // Verify clipboard was set -// verify(mockClipboardManager).setPrimaryClip(clipDataCaptor.capture()) -// -// // Verify the ClipData contains correct text -// val capturedClipData = clipDataCaptor.value -// assertEquals(testClipText, capturedClipData.getItemAt(0).text.toString()) -// assertEquals("Received Text", capturedClipData.description.label.toString()) -// -// // Verify toast was shown (on Android < 13) using Robolectric's ShadowToast -// val latestToast = ShadowToast.getLatestToast() -// assertEquals("Copied to clipboard", ShadowToast.getTextOfLatestToast()) -// -// // Verify notification was cancelled with correct ID -// verify(mockNotificationManager).cancel(testNotificationId) -// } -// -// @Test -// @Config(sdk = [Build.VERSION_CODES.TIRAMISU]) // Android 13 (API 33) -// fun `onReceive with ACTION_COPY does not show toast on Android 13+`() { -// // Arrange -// val testClipText = "Test clipboard text" -// val testNotificationId = 12345 -// val intent = Intent().apply { -// action = "ACTION_COPY" -// putExtra("CLIP_TEXT", testClipText) -// putExtra("NOTIFICATION_ID", testNotificationId) -// } -// -// // Clear any previous toasts -// ShadowToast.reset() -// -// // Act -// notificationReceiver.onReceive(context, intent) -// -// // Assert -// // Verify clipboard was still set -// verify(mockClipboardManager).setPrimaryClip(any()) -// -// // Verify toast was NOT shown (on Android 13+) -// assertNull("No toast should be shown on Android 13+", ShadowToast.getLatestToast()) -// -// // Verify notification was still cancelled -// verify(mockNotificationManager).cancel(testNotificationId) -// } -// -// @Test -// fun `onReceive with ACTION_COPY and null CLIP_TEXT returns early`() { -// // Arrange -// val intent = Intent().apply { -// action = "ACTION_COPY" -// // No CLIP_TEXT extra -// } -// -// // Clear any previous toasts -// ShadowToast.reset() -// -// // Act -// notificationReceiver.onReceive(context, intent) -// -// // Assert -// // Verify nothing was called since we returned early -// verify(mockClipboardManager, never()).setPrimaryClip(any()) -// assertNull("No toast should be shown when CLIP_TEXT is null", ShadowToast.getLatestToast()) -// verify(mockNotificationManager, never()).cancel(any()) -// } -// -// @Test -// fun `onReceive with ACTION_COPY and empty CLIP_TEXT still processes`() { -// // Arrange -// val testClipText = "" -// val testNotificationId = 12345 -// val intent = Intent().apply { -// action = "ACTION_COPY" -// putExtra("CLIP_TEXT", testClipText) -// putExtra("NOTIFICATION_ID", testNotificationId) -// } -// -// // Act -// notificationReceiver.onReceive(context, intent) -// -// // Assert -// // Verify clipboard was set with empty text -// verify(mockClipboardManager).setPrimaryClip(any()) -// assertEquals("Copied to clipboard", ShadowToast.getTextOfLatestToast()) -// verify(mockNotificationManager).cancel(testNotificationId) -// } -// -// @Test -// fun `onReceive with ACTION_COPY and no NOTIFICATION_ID does not cancel notification`() { -// // Arrange -// val testClipText = "Test text" -// val intent = Intent().apply { -// action = "ACTION_COPY" -// putExtra("CLIP_TEXT", testClipText) -// // No NOTIFICATION_ID extra (defaults to 0) -// } -// -// // Act -// notificationReceiver.onReceive(context, intent) -// -// // Assert -// // Verify clipboard operations still happened -// verify(mockClipboardManager).setPrimaryClip(any()) -// assertEquals("Copied to clipboard", ShadowToast.getTextOfLatestToast()) -// -// // Verify notification was NOT cancelled since ID was 0 -// verify(mockNotificationManager, never()).cancel(any()) -// } -// -// @Test -// fun `onReceive with unknown action does nothing`() { -// // Arrange -// val intent = Intent().apply { -// action = "UNKNOWN_ACTION" -// } -// -// // Clear any previous toasts -// ShadowToast.reset() -// -// // Act -// notificationReceiver.onReceive(context, intent) -// -// // Assert -// // Verify no interactions occurred -// verify(mockNotificationManager, never()).cancel(any()) -// verify(mockClipboardManager, never()).setPrimaryClip(any()) -// assertNull("No toast should be shown for unknown action", ShadowToast.getLatestToast()) -// -// // Verify no services were stopped -// val nextStoppedService = shadowApplication.nextStoppedService -// assertNull(nextStoppedService) -// } -// -// @Test -// fun `onReceive with null action does nothing`() { -// // Arrange -// val intent = Intent() // No action set -// -// // Clear any previous toasts -// ShadowToast.reset() -// -// // Act -// notificationReceiver.onReceive(context, intent) -// -// // Assert -// // Verify no interactions occurred -// verify(mockNotificationManager, never()).cancel(any()) -// verify(mockClipboardManager, never()).setPrimaryClip(any()) -// assertNull("No toast should be shown for null action", ShadowToast.getLatestToast()) -// } -// -// @Test -// fun `onReceive with ACTION_COPY verifies ClipData creation parameters`() { -// // Arrange -// val testClipText = "Sample clipboard content" -// val intent = Intent().apply { -// action = "ACTION_COPY" -// putExtra("CLIP_TEXT", testClipText) -// putExtra("NOTIFICATION_ID", 999) -// } -// -// val clipDataCaptor = ArgumentCaptor.forClass(ClipData::class.java) -// -// // Act -// notificationReceiver.onReceive(context, intent) -// -// // Assert -// verify(mockClipboardManager).setPrimaryClip(clipDataCaptor.capture()) -// -// val capturedClipData = clipDataCaptor.value -// // Verify ClipData properties -// assertEquals("Received Text", capturedClipData.description.label.toString()) -// assertEquals(1, capturedClipData.itemCount) -// assertEquals(testClipText, capturedClipData.getItemAt(0).text.toString()) -// } -// -// @Test -// fun `onReceive with ACTION_DISMISS only cancels notification 1001`() { -// // Arrange -// val intent = Intent().apply { -// action = "ACTION_DISMISS" -// } -// -// // Act -// notificationReceiver.onReceive(context, intent) -// -// // Assert -// // Verify only notification 1001 was cancelled, not any other ID -// verify(mockNotificationManager, times(1)).cancel(1001) -// verify(mockNotificationManager, never()).cancel(0) -// verify(mockNotificationManager, never()).cancel(999) -// } -//} \ No newline at end of file + +import android.app.NotificationManager +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.content.Intent +import android.os.Build +import androidx.test.core.app.ApplicationProvider +import com.aubynsamuel.clipsync.bluetooth.BluetoothService +import com.aubynsamuel.clipsync.notification.NotificationReceiver +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.ArgumentCaptor +import org.mockito.Mock +import org.mockito.Mockito.never +import org.mockito.Mockito.times +import org.mockito.Mockito.verify +import org.mockito.MockitoAnnotations +import org.mockito.kotlin.any +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.shadows.ShadowApplication +import org.robolectric.shadows.ShadowToast + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [Build.VERSION_CODES.S]) // Testing with Android 12 (API 31) +class NotificationReceiverTest { + + private lateinit var notificationReceiver: NotificationReceiver + private lateinit var context: Context + private lateinit var shadowApplication: ShadowApplication + + @Mock + private lateinit var mockNotificationManager: NotificationManager + + @Mock + private lateinit var mockClipboardManager: ClipboardManager + + @Before + fun setUp() { + MockitoAnnotations.openMocks(this) + + notificationReceiver = NotificationReceiver() + context = ApplicationProvider.getApplicationContext() + shadowApplication = ShadowApplication() + + // Mock system services + shadowApplication.setSystemService(Context.NOTIFICATION_SERVICE, mockNotificationManager) + shadowApplication.setSystemService(Context.CLIPBOARD_SERVICE, mockClipboardManager) + } + + @Test + fun `onReceive with ACTION_DISMISS stops service and cancels notification`() { + // Arrange + val intent = Intent().apply { + action = "ACTION_DISMISS" + } + + // Act + notificationReceiver.onReceive(context, intent) + + // Assert + // Verify service was stopped + val nextStoppedService = shadowApplication.nextStoppedService + assertEquals(BluetoothService::class.java.name, nextStoppedService.component?.className) + + // Verify notification was cancelled + verify(mockNotificationManager).cancel(1001) + } + + @Test + fun `onReceive with ACTION_COPY copies text to clipboard and shows toast on older Android`() { + // Arrange + val testClipText = "Test clipboard text" + val testNotificationId = 12345 + val intent = Intent().apply { + action = "ACTION_COPY" + putExtra("CLIP_TEXT", testClipText) + putExtra("NOTIFICATION_ID", testNotificationId) + } + + val clipDataCaptor = ArgumentCaptor.forClass(ClipData::class.java) + + // Act + notificationReceiver.onReceive(context, intent) + + // Assert + // Verify clipboard was set + verify(mockClipboardManager).setPrimaryClip(clipDataCaptor.capture()) + + // Verify the ClipData contains correct text + val capturedClipData = clipDataCaptor.value + assertEquals(testClipText, capturedClipData.getItemAt(0).text.toString()) + assertEquals("Received Text", capturedClipData.description.label.toString()) + + // Verify toast was shown (on Android < 13) using Robolectric's ShadowToast + val latestToast = ShadowToast.getLatestToast() + assertEquals("Copied to clipboard", ShadowToast.getTextOfLatestToast()) + + // Verify notification was cancelled with correct ID + verify(mockNotificationManager).cancel(testNotificationId) + } + + @Test + @Config(sdk = [Build.VERSION_CODES.TIRAMISU]) // Android 13 (API 33) + fun `onReceive with ACTION_COPY does not show toast on Android 13+`() { + // Arrange + val testClipText = "Test clipboard text" + val testNotificationId = 12345 + val intent = Intent().apply { + action = "ACTION_COPY" + putExtra("CLIP_TEXT", testClipText) + putExtra("NOTIFICATION_ID", testNotificationId) + } + + // Clear any previous toasts + ShadowToast.reset() + + // Act + notificationReceiver.onReceive(context, intent) + + // Assert + // Verify clipboard was still set + verify(mockClipboardManager).setPrimaryClip(any()) + + // Verify toast was NOT shown (on Android 13+) + assertNull("No toast should be shown on Android 13+", ShadowToast.getLatestToast()) + + // Verify notification was still cancelled + verify(mockNotificationManager).cancel(testNotificationId) + } + + @Test + fun `onReceive with ACTION_COPY and null CLIP_TEXT returns early`() { + // Arrange + val intent = Intent().apply { + action = "ACTION_COPY" + // No CLIP_TEXT extra + } + + // Clear any previous toasts + ShadowToast.reset() + + // Act + notificationReceiver.onReceive(context, intent) + + // Assert + // Verify nothing was called since we returned early + verify(mockClipboardManager, never()).setPrimaryClip(any()) + assertNull("No toast should be shown when CLIP_TEXT is null", ShadowToast.getLatestToast()) + verify(mockNotificationManager, never()).cancel(any()) + } + + @Test + fun `onReceive with ACTION_COPY and empty CLIP_TEXT still processes`() { + // Arrange + val testClipText = "" + val testNotificationId = 12345 + val intent = Intent().apply { + action = "ACTION_COPY" + putExtra("CLIP_TEXT", testClipText) + putExtra("NOTIFICATION_ID", testNotificationId) + } + + // Act + notificationReceiver.onReceive(context, intent) + + // Assert + // Verify clipboard was set with empty text + verify(mockClipboardManager).setPrimaryClip(any()) + assertEquals("Copied to clipboard", ShadowToast.getTextOfLatestToast()) + verify(mockNotificationManager).cancel(testNotificationId) + } + + @Test + fun `onReceive with ACTION_COPY and no NOTIFICATION_ID does not cancel notification`() { + // Arrange + val testClipText = "Test text" + val intent = Intent().apply { + action = "ACTION_COPY" + putExtra("CLIP_TEXT", testClipText) + // No NOTIFICATION_ID extra (defaults to 0) + } + + // Act + notificationReceiver.onReceive(context, intent) + + // Assert + // Verify clipboard operations still happened + verify(mockClipboardManager).setPrimaryClip(any()) + assertEquals("Copied to clipboard", ShadowToast.getTextOfLatestToast()) + + // Verify notification was NOT cancelled since ID was 0 + verify(mockNotificationManager, never()).cancel(any()) + } + + @Test + fun `onReceive with unknown action does nothing`() { + // Arrange + val intent = Intent().apply { + action = "UNKNOWN_ACTION" + } + + // Clear any previous toasts + ShadowToast.reset() + + // Act + notificationReceiver.onReceive(context, intent) + + // Assert + // Verify no interactions occurred + verify(mockNotificationManager, never()).cancel(any()) + verify(mockClipboardManager, never()).setPrimaryClip(any()) + assertNull("No toast should be shown for unknown action", ShadowToast.getLatestToast()) + + // Verify no services were stopped + val nextStoppedService = shadowApplication.nextStoppedService + assertNull(nextStoppedService) + } + + @Test + fun `onReceive with null action does nothing`() { + // Arrange + val intent = Intent() // No action set + + // Clear any previous toasts + ShadowToast.reset() + + // Act + notificationReceiver.onReceive(context, intent) + + // Assert + // Verify no interactions occurred + verify(mockNotificationManager, never()).cancel(any()) + verify(mockClipboardManager, never()).setPrimaryClip(any()) + assertNull("No toast should be shown for null action", ShadowToast.getLatestToast()) + } + + @Test + fun `onReceive with ACTION_COPY verifies ClipData creation parameters`() { + // Arrange + val testClipText = "Sample clipboard content" + val intent = Intent().apply { + action = "ACTION_COPY" + putExtra("CLIP_TEXT", testClipText) + putExtra("NOTIFICATION_ID", 999) + } + + val clipDataCaptor = ArgumentCaptor.forClass(ClipData::class.java) + + // Act + notificationReceiver.onReceive(context, intent) + + // Assert + verify(mockClipboardManager).setPrimaryClip(clipDataCaptor.capture()) + + val capturedClipData = clipDataCaptor.value + // Verify ClipData properties + assertEquals("Received Text", capturedClipData.description.label.toString()) + assertEquals(1, capturedClipData.itemCount) + assertEquals(testClipText, capturedClipData.getItemAt(0).text.toString()) + } + + @Test + fun `onReceive with ACTION_DISMISS only cancels notification 1001`() { + // Arrange + val intent = Intent().apply { + action = "ACTION_DISMISS" + } + + // Act + notificationReceiver.onReceive(context, intent) + + // Assert + // Verify only notification 1001 was cancelled, not any other ID + verify(mockNotificationManager, times(1)).cancel(1001) + verify(mockNotificationManager, never()).cancel(0) + verify(mockNotificationManager, never()).cancel(999) + } +} \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..e708b1c Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradlew b/gradlew new file mode 100644 index 0000000..4f906e0 --- /dev/null +++ b/gradlew @@ -0,0 +1,185 @@ +#!/usr/bin/env sh + +# +# Copyright 2015 the original author or authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin or MSYS, switch paths to Windows format before running java +if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=`expr $i + 1` + done + case $i in + 0) set -- ;; + 1) set -- "$args0" ;; + 2) set -- "$args0" "$args1" ;; + 3) set -- "$args0" "$args1" "$args2" ;; + 4) set -- "$args0" "$args1" "$args2" "$args3" ;; + 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=`save "$@"` + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +exec "$JAVACMD" "$@"