1 package expo.modules.clipboard 2 3 import android.content.ClipData 4 import android.content.ClipDescription 5 import android.content.ClipboardManager 6 import android.content.Context 7 import android.net.Uri 8 import android.os.Build 9 import androidx.test.core.app.ApplicationProvider 10 import expo.modules.kotlin.exception.CodedException 11 import expo.modules.kotlin.exception.errorCodeOf 12 import expo.modules.test.core.ModuleMock 13 import expo.modules.test.core.ModuleMockHolder 14 import expo.modules.test.core.assertCodedException 15 import io.mockk.confirmVerified 16 import io.mockk.verify 17 import org.junit.Assert.assertEquals 18 import org.junit.Assert.assertFalse 19 import org.junit.Assert.assertTrue 20 import org.junit.Test 21 import org.junit.runner.RunWith 22 import org.robolectric.RobolectricTestRunner 23 import org.robolectric.annotation.Config 24 import org.robolectric.annotation.Implementation 25 import org.robolectric.annotation.Implements 26 import org.robolectric.shadows.ShadowContextImpl 27 28 private interface ClipboardModuleTestInterface { 29 @Throws(CodedException::class) getStringAsyncnull30 fun getStringAsync(options: GetStringOptions = GetStringOptions()): String 31 32 @Throws(CodedException::class) 33 fun setStringAsync(content: String, options: SetStringOptions = SetStringOptions()): Boolean 34 35 @Throws(CodedException::class) 36 fun hasStringAsync(): Boolean 37 } 38 39 private inline fun withClipboardMock( 40 block: ModuleMockHolder<ClipboardModuleTestInterface, ClipboardModule>.() -> Unit 41 ) = ModuleMock.createMock(ClipboardModuleTestInterface::class, ClipboardModule(), block = block) 42 43 @RunWith(RobolectricTestRunner::class) 44 @Config(sdk = [Build.VERSION_CODES.P]) // API 28 45 class ClipboardModuleTest { 46 47 @Test 48 fun `should save to and read from clipboard`() = withClipboardMock { 49 // write to clipboard 50 val writeResult = module.setStringAsync("album dumbledore") 51 52 // read from clipboard 53 val readResult = module.getStringAsync() 54 55 assertTrue(writeResult) 56 assertEquals("album dumbledore", readResult) 57 } 58 59 @Test 60 fun `should get empty string when clipboard is empty`() = withClipboardMock { 61 // This requires API 28 62 clipboardManager.clearPrimaryClip() 63 64 val content = module.getStringAsync() 65 66 assertTrue("Clipboard content should be empty", content.isEmpty()) 67 } 68 69 @Test 70 fun `getStringAsync should support HTML`() = withClipboardMock { 71 clipboardManager.setPrimaryClip( 72 ClipData.newHtmlText(null, "hello world", "<p>hello world</p>") 73 ) 74 75 val plainResult = module.getStringAsync() 76 val htmlResult = module.getStringAsync( 77 GetStringOptions().apply { 78 preferredFormat = StringFormat.HTML 79 } 80 ) 81 assertEquals("hello world", plainResult) 82 assertEquals("<p>hello world</p>", htmlResult) 83 } 84 85 @Test 86 fun `setStringAsync should support HTML`() = withClipboardMock { 87 88 module.setStringAsync( 89 "<p>hello</p>", 90 SetStringOptions().apply { 91 inputFormat = StringFormat.HTML 92 } 93 ) 94 95 assertTrue( 96 clipboardManager.primaryClipDescription?.hasMimeType(ClipDescription.MIMETYPE_TEXT_HTML) == true 97 ) 98 assertEquals("<p>hello</p>", clipboardManager.primaryClip!!.getItemAt(0).htmlText) 99 } 100 101 @Test 102 fun `hasStringAsync should return correct values`() = withClipboardMock { 103 // plain text 104 clipboardManager.setPrimaryClip(ClipData.newPlainText(null, "hello world")) 105 var result = module.hasStringAsync() 106 assertTrue("hasStringAsync returns false for plain text (should be true)", result) 107 108 // html 109 clipboardManager.setPrimaryClip( 110 ClipData.newHtmlText(null, "hello world", "<p>hello world</p>") 111 ) 112 result = module.hasStringAsync() 113 assertTrue("hasStringAsync returns false for plain text (should be true)", result) 114 115 // non-text content type 116 clipboardManager.setPrimaryClip(ClipData.newRawUri(null, Uri.EMPTY)) 117 result = module.hasStringAsync() 118 assertFalse("hasStringAsync returns true for non-text (should be false)", result) 119 120 // empty clipboard 121 clipboardManager.clearPrimaryClip() 122 result = module.hasStringAsync() 123 assertFalse("hasStringAsync returns true for empty clipboard (should be false)", result) 124 } 125 126 @Test 127 fun `should emit events when clipboard changes`() = withClipboardMock { 128 // update clipboard content 129 val result = module.setStringAsync("severus snape") 130 131 // assert 132 assertTrue(result) 133 verify { 134 eventEmitter.emit( 135 CLIPBOARD_CHANGED_EVENT_NAME, 136 match { 137 it.getStringArrayList("contentTypes")?.contains("plain-text") == true 138 } 139 ) 140 } 141 confirmVerified(eventEmitter) 142 } 143 144 @Test 145 fun `shouldn't emit events when in background`() = withClipboardMock { 146 // prepare 147 controller.onActivityEntersBackground() 148 149 // update clipboard content 150 module.setStringAsync("ronald weasley") 151 152 // assert that emit() was NOT called 153 verify(inverse = true) { eventEmitter.emit(CLIPBOARD_CHANGED_EVENT_NAME, any()) } 154 confirmVerified(eventEmitter) 155 } 156 157 @Test 158 @Config(shadows = [ContextWithoutClipboardService::class]) 159 fun `should throw when ClipboardManager is unavailable`() = withClipboardMock { 160 val exception = runCatching { module.hasStringAsync() }.exceptionOrNull() 161 162 assertCodedException(exception) { 163 assertEquals(errorCodeOf<ClipboardUnavailableException>(), it.code) 164 } 165 } 166 167 private val clipboardManager: ClipboardManager 168 get() = ApplicationProvider 169 .getApplicationContext<Context>() 170 .getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager 171 } 172 173 @Implements(className = ShadowContextImpl.CLASS_NAME) 174 class ContextWithoutClipboardService : ShadowContextImpl() { 175 @Implementation 176 override fun getSystemService(name: String): Any? = when (name) { 177 Context.CLIPBOARD_SERVICE -> null 178 else -> super.getSystemService(name) 179 } 180 } 181